← Commit history

Laptop full-toolbar hero, lint-pass/lint-fail/CLI feature images, open-source section + Rust source

John Lauer ·3a45f22cf6 ·4mo ago ·parent d1e2f68
10 files changed +1661−1
Cargo.tomladded+24
@@ -0,0 +1,24 @@+[package]+name = "adom-lbr"+version = "0.1.4"+edition = "2021"+description = "EAGLE .lbr generator, linter, and validator for Fusion 360 Electronics. Converts KiCad symbols + footprints to complete EAGLE libraries."++[[bin]]+name = "adom-lbr"+path = "src/main.rs"++[dependencies]+clap = { version = "4", features = ["derive"] }+clap_complete = "4"+regex-lite = "0.1"+serde = { version = "1", features = ["derive"] }+serde_json = "1"+quick-xml = { version = "0.36", features = ["serialize"] }+tiny_http = "0.12"+anyhow = "1"++[profile.release]+opt-level = "z"+lto = true+strip = true
README.md+28−1
@@ -1,9 +1,27 @@ # adom-lbr -**An EAGLE `.lbr` generator, linter, and validator for Fusion 360 Electronics — built from KiCad sources.**+**An EAGLE `.lbr` generator, linter, and validator for Fusion 360 Electronics — built from KiCad sources, standalone Rust, no local EAGLE.**  adom-lbr turns a KiCad `.kicad_sym` (and optional `.kicad_mod`) into a true EAGLE `.lbr` with a complete deviceset — symbol + package + pin→pad connects — that Fusion 360 Electronics imports cleanly. It can also synthesize a placeable symbol from a footprint's pads when there's no schematic symbol, convert an `.lbr` back to KiCad, and verify a library through `service-kicad`. Standalone Rust, no Node, no gallia. +## The library at a glance++![The library viewer: stats and a clean lint pass](docs/lintpass.png)++Load an `.lbr` and the viewer breaks it down at a glance — **symbols, packages, devicesets, pins, pads** — and re-lints it server-side. A green **Lint passed** badge means the library is ready to import into Fusion 360 Electronics with no surprises. The top bar carries the **Send to** targets (KiCad / Fusion / Altium / OrCAD).++## The linter catches what Fusion rejects++![A lint failure: empty pin-to-pad connects](docs/lintfail.png)++This is the whole point. Fusion will silently show "No Components" or refuse to place a part when a library is subtly broken — adom-lbr catches it first. Here the deviceset has **no pin-to-pad connect mappings**, so the linter flags `empty-connects` with a plain-English reason: *"Fusion cannot use this component."* It also catches missing packages, devices with no package attribute, malformed XML, and invisible symbol bodies — each with a code and a fix.++## Generate from the command line++![Generating an .lbr from KiCad sources](docs/generate.png)++`generate` builds the whole library in one shot from a `.kicad_sym` (+ optional `.kicad_mod`), lint-checks it, and writes a native `.lbr`. The same engine powers the **Send to → Fusion/Altium/OrCAD** flow in adom-symbol and adom-footprint, so whatever you see in those apps is exactly what lands in your library.+ ## What it does  - **`generate`** — `.kicad_sym` (+ `.kicad_mod`) → native `.lbr`, lint-checked for Fusion compatibility.@@ -15,6 +33,15 @@ adom-lbr turns a KiCad `.kicad_sym` (and optional `.kicad_mod`) into a true EAGL  Fusion 360 (open the `.lbr` directly), Altium / OrCAD (download the `.lbr` + import steps; native exporters on the roadmap), KiCad (converted back to `.kicad_sym`). +## Open source — check it out, vibe-code it, send it back++adom-lbr is MIT and **fully open**. The complete Rust source is in this page's **Files** tab — browse it, or grab `src/` + `Cargo.toml`.++1. **Build it.** `cargo build --release` drops a standalone binary (no Node, no gallia).+2. **Point your install at your checkout.** `adompkg link john/adom-lbr ./adom-lbr` so the `adom-lbr` you run is your local copy.+3. **Vibe-code a change.** Open the folder in Claude Code and describe what you want — a new lint rule, another export target, a smarter symbol synthesizer. The lint rules live in one readable list, easy to extend.+4. **Send it back.** Open a **pull request** on this page (the wiki reviews and merges per-repo PRs), or start a discussion thread. Good additions get folded into the shipped app.+ ## Part of the Adom EDA family  Used by **chip-fetcher** and the Send-to flow across **adom-symbol** and **adom-footprint**; pairs with **ds2sf**.
docs/generate.pngadded
⋯ 1 unchanged line ⋯
docs/hero.pngadded
⋯ 1 unchanged line ⋯
docs/lintfail.pngadded
⋯ 1 unchanged line ⋯
docs/lintpass.pngadded
⋯ 1 unchanged line ⋯
src/app.htmladded+256
@@ -0,0 +1,256 @@+<!doctype html>+<html lang="en">+<head>+  <meta charset="utf-8">+  <meta name="viewport" content="width=device-width, initial-scale=1">+  <title>adom-lbr</title>+  <link rel="icon" type="image/svg+xml" href="favicon.svg">+  <style>+    :root {+      --bg:#0d1117; --panel:#11161f; --panel2:#0f141c; --border:#222b38; --border2:#2b3645;+      --text:#e6edf3; --muted:#8b949e; --text3:#5b6675; --accent:#00b8b1; --accent2:#00e6dc;+      --ok:#2ea043; --ok2:#3fb950; --err:#f85149; --warn:#d29922;+    }+    * { box-sizing:border-box; }+    html,body { margin:0; height:100%; }+    body { background:var(--bg); color:var(--text); font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; display:flex; flex-direction:column; }++    header.app {+      height:44px; flex:0 0 44px; display:grid; grid-template-columns:auto 1fr auto;+      align-items:center; gap:14px; padding:0 14px; border-bottom:1px solid var(--border);+      background:linear-gradient(180deg,#11161f,#0d1117);+    }+    header.app .ident { display:flex; align-items:center; gap:8px; min-width:0; }+    header.app .ident svg { width:16px; height:16px; color:var(--accent2); flex:0 0 16px; }+    header.app .app-name { font-weight:700; font-size:13px; letter-spacing:.2px; white-space:nowrap; }+    header.app .app-sub { color:var(--muted); font-size:12px; white-space:nowrap; }+    header.app .app-sub::before { content:"·"; margin:0 6px; color:var(--text3); }+    header.app .subject { justify-self:center; display:flex; align-items:baseline; gap:10px; min-width:0; }+    header.app .subject-name { font-weight:600; font-size:13px; max-width:42vw; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }+    header.app .subject-meta { font-family:'JetBrains Mono',ui-monospace,monospace; font-size:11px; color:var(--muted); white-space:nowrap; }+    header.app .actions { display:flex; align-items:center; gap:8px; }+    header.app .conn { display:inline-flex; align-items:center; gap:6px; font-size:11px; color:var(--muted); }+    header.app .conn .dot { width:8px; height:8px; border-radius:50%; background:var(--text3); transition:background .2s, box-shadow .2s; }+    header.app .conn.live .dot { background:var(--accent2); box-shadow:0 0 8px var(--accent2); }+    header.app .sendto { display:inline-flex; align-items:center; gap:4px; }+    header.app .sendto .sendlabel { font-size:11px; color:var(--muted); margin-right:2px; }+    header.app .sendto .sbtn { font:inherit; font-size:11px; color:var(--text2,#cfd6e4); background:var(--surface2,#1c2230); border:1px solid var(--border,#2a3140); border-radius:6px; padding:3px 8px; cursor:pointer; transition:background .15s,border-color .15s,color .15s; }+    header.app .sendto .sbtn:hover { background:var(--accent2,#37c8a6); border-color:var(--accent2,#37c8a6); color:#0b0f17; }+    header.app .sendto .sbtn:disabled { opacity:.5; cursor:progress; }+    #toast { position:fixed; left:50%; bottom:18px; transform:translateX(-50%) translateY(20px); background:#1c2230; color:#e8ecf4; border:1px solid #2a3140; border-radius:10px; padding:10px 16px; font-size:13px; max-width:84vw; box-shadow:0 8px 28px rgba(0,0,0,.45); opacity:0; pointer-events:none; transition:opacity .25s, transform .25s; z-index:9999; }+    #toast.show { opacity:1; transform:translateX(-50%) translateY(0); }+    #toast.ok { border-color:#37c8a6; } #toast.err { border-color:#e0698a; }+    #bridgeModal{position:fixed;inset:0;z-index:100001;display:none;align-items:center;justify-content:center;background:rgba(2,5,10,.62);backdrop-filter:blur(4px);padding:20px}+    #bridgeModal.show{display:flex}+    #bridgeModal .bm-card{max-width:min(94vw,440px);background:rgba(17,22,31,.99);border:1px solid var(--border2,#2b3645);border-radius:16px;padding:22px 22px 18px;box-shadow:0 30px 90px rgba(0,0,0,.7);text-align:center}+    #bridgeModal .bm-icon{font-size:34px;margin-bottom:6px}+    #bridgeModal .bm-title{font-size:16px;font-weight:700;margin-bottom:8px;color:var(--text,#e6edf3)}+    #bridgeModal .bm-body{font-size:13px;line-height:1.55;color:var(--muted,#8b949e);margin-bottom:16px}+    #bridgeModal .bm-actions{display:flex;flex-direction:column;gap:9px}+    #bridgeModal .bm-go{display:block;text-decoration:none;background:linear-gradient(180deg,#00b8b1,#009a94);color:#04201f;font-weight:700;font-size:13px;padding:10px;border-radius:10px}+    #bridgeModal .bm-dismiss{background:transparent;border:1px solid var(--border2,#2b3645);color:var(--muted,#8b949e);font-size:12.5px;padding:8px;border-radius:10px;cursor:pointer}+    #bridgeModal .bm-dismiss:hover{color:var(--text,#e6edf3)}++    main { flex:1; display:flex; align-items:flex-start; justify-content:center; padding:28px; min-height:0; overflow:auto; }+    .card { background:var(--panel); border:1px solid var(--border); border-radius:14px; padding:22px 24px; box-shadow:0 24px 80px rgba(0,0,0,.5); max-width:min(94vw,720px); width:100%; }+    .topline { display:flex; align-items:center; justify-content:space-between; gap:14px; }+    .libname { font-size:21px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }+    .badge { flex:0 0 auto; display:inline-flex; align-items:center; gap:7px; font-size:13px; font-weight:700; padding:6px 13px; border-radius:999px; }+    .badge .pip { width:9px; height:9px; border-radius:50%; }+    .badge.pass { background:rgba(46,160,67,.14); color:var(--ok2); border:1px solid rgba(46,160,67,.4); }+    .badge.pass .pip { background:var(--ok2); box-shadow:0 0 8px var(--ok2); }+    .badge.fail { background:rgba(248,81,73,.13); color:var(--err); border:1px solid rgba(248,81,73,.4); }+    .badge.fail .pip { background:var(--err); box-shadow:0 0 8px var(--err); }++    .counts { display:flex; flex-wrap:wrap; gap:10px; margin-top:18px; }+    .stat { flex:1 1 0; min-width:84px; background:var(--panel2); border:1px solid var(--border); border-radius:10px; padding:11px 13px; }+    .stat .n { font-size:20px; font-weight:700; font-family:'JetBrains Mono',ui-monospace,monospace; }+    .stat .k { color:var(--muted); font-size:11px; margin-top:1px; text-transform:uppercase; letter-spacing:.4px; }++    .section { margin-top:18px; }+    .section h3 { font-size:11px; letter-spacing:.5px; text-transform:uppercase; color:var(--muted); margin:0 0 8px; }+    .issue { display:flex; gap:9px; align-items:flex-start; padding:9px 11px; border-radius:9px; font-size:13px; line-height:1.45; margin-bottom:7px; }+    .issue.e { background:rgba(248,81,73,.08); border:1px solid rgba(248,81,73,.28); }+    .issue.w { background:rgba(210,153,34,.08); border:1px solid rgba(210,153,34,.28); }+    .issue .code { flex:0 0 auto; font-family:'JetBrains Mono',ui-monospace,monospace; font-size:11px; padding:1px 7px; border-radius:6px; margin-top:1px; }+    .issue.e .code { background:rgba(248,81,73,.18); color:var(--err); }+    .issue.w .code { background:rgba(210,153,34,.18); color:var(--warn); }+    .clean { color:var(--ok2); font-size:13px; display:flex; align-items:center; gap:8px; }+    .empty { color:var(--text3); font-size:13px; padding:48px 20px; text-align:center; }+    code { font-family:'JetBrains Mono',ui-monospace,monospace; background:var(--panel2); border:1px solid var(--border); border-radius:5px; padding:1px 6px; font-size:.9em; }+    footer { flex:0 0 auto; text-align:center; color:var(--text3); font-size:11px; padding:8px; border-top:1px solid var(--border); }++    #tt { position:fixed; z-index:99999; max-width:340px; background:rgba(22,27,34,0.97); backdrop-filter:blur(16px);+      border:1px solid rgba(0,184,177,0.25); border-radius:10px; padding:10px 14px; color:#e6edf3;+      font-family:-apple-system,sans-serif; font-size:12px; font-weight:400; letter-spacing:0; text-transform:none;+      line-height:1.55; white-space:pre-wrap; pointer-events:none; opacity:0; transition:opacity .18s ease;+      box-shadow:0 12px 40px rgba(0,0,0,0.55); }+    #tt.show { opacity:1; }+    [data-tooltip] { cursor:help; }+  </style>+</head>+<body>+  <header class="app">+    <div class="ident" data-tooltip="adom-lbr — a standalone Rust tool that converts KiCad symbols/footprints into EAGLE .lbr libraries for Fusion 360 Electronics, then lints them so Fusion won't silently show 'No Components'. No local EAGLE/Fusion install needed.">+      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5.5A1.5 1.5 0 0 1 5.5 4H9a1.5 1.5 0 0 1 1.5 1.5V20a1.5 1.5 0 0 0-1.5-1.5H5.5A1.5 1.5 0 0 1 4 17z"/><path d="M20 5.5A1.5 1.5 0 0 0 18.5 4H15a1.5 1.5 0 0 0-1.5 1.5V20a1.5 1.5 0 0 1 1.5-1.5h3.5a1.5 1.5 0 0 0 1.5-1.5z"/></svg>+      <span class="app-name">adom-lbr</span>+      <span class="app-sub">EAGLE library linter</span>+    </div>+    <div class="subject" id="subject">+      <span class="subject-name" id="subjectName">—</span>+      <span class="subject-meta" id="subjectMeta"></span>+    </div>+    <div class="actions">+      <span class="sendto" id="sendto" style="display:none" data-tooltip="Send this library to a desktop EDA tool via Adom Desktop. Fusion 360 / Altium / OrCAD import the .lbr directly; KiCad receives a native .kicad_sym converted from it.">+        <span class="sendlabel">Send to</span>+        <button class="sbtn" data-target="kicad">KiCad</button>+        <button class="sbtn" data-target="fusion360">Fusion</button>+        <button class="sbtn" data-target="altium">Altium</button>+        <button class="sbtn" data-target="orcad">OrCAD</button>+      </span>+      <span class="conn" id="conn" data-tooltip="Live connection to the adom-lbr Rust server. Green = the UI is polling /state and the AI can drive this view in real time (push an .lbr via /load, run /eval, read the console).">+        <span class="dot"></span><span id="connText">connecting…</span>+      </span>+    </div>+  </header>++  <main>+    <div class="card" id="card">+      <div class="empty" id="empty">Waiting for a library… run <code>adom-lbr generate</code> then push the <code>.lbr</code>, or the AI will load one here.</div>+      <div id="content" style="display:none">+        <div class="topline">+          <div class="libname" id="libName"></div>+          <div class="badge" id="badge"><span class="pip"></span><span id="badgeText"></span></div>+        </div>+        <div class="counts" id="counts"></div>+        <div class="section" id="errSection" style="display:none">+          <h3 data-tooltip="Hard errors block the component in Fusion 360 — an empty packages/connects section or a malformed deviceset makes the part import as 'No Components'.">Errors</h3>+          <div id="errList"></div>+        </div>+        <div class="section" id="warnSection" style="display:none">+          <h3 data-tooltip="Warnings won't block import but usually mean the symbol/pads won't look or connect the way you expect in Fusion.">Warnings</h3>+          <div id="warnList"></div>+        </div>+        <div class="section" id="cleanSection" style="display:none">+          <div class="clean">✓ Lint passed — this library is ready to import into Fusion 360 Electronics.</div>+        </div>+      </div>+    </div>+  </main>+  <footer>standalone Rust — KiCad → EAGLE .lbr, no gallia, no Node, no local EAGLE</footer>++  <div id="tt"></div>+  <div id="toast"></div>++  <script>+    (function(){+      const post = (level,args)=>{ try{ navigator.sendBeacon('console', JSON.stringify({level,msg:args.map(a=>{try{return typeof a==='string'?a:JSON.stringify(a)}catch{return String(a)}}).join(' ')})); }catch(e){} };+      ['log','warn','error','info'].forEach(l=>{ const o=console[l].bind(console); console[l]=(...a)=>{ post(l,a); o(...a); }; });+      window.addEventListener('error', e=> post('error',[e.message+' @'+(e.filename||'')+':'+e.lineno]));+    })();++    (function(){+      const tt=document.getElementById('tt'); let timer=null, cur=null;+      function place(el){ const r=el.getBoundingClientRect(); tt.style.left='0px'; tt.style.top='0px'; tt.classList.add('show');+        const tr=tt.getBoundingClientRect(); let x=r.left+r.width/2-tr.width/2; let y=r.top-tr.height-8;+        x=Math.max(8,Math.min(x,innerWidth-tr.width-8)); if(y<8) y=r.bottom+8;+        tt.style.left=x+'px'; tt.style.top=y+'px'; }+      document.addEventListener('mouseover',e=>{ const el=e.target.closest('[data-tooltip]'); if(!el||el===cur) return; cur=el;+        clearTimeout(timer); timer=setTimeout(()=>{ tt.textContent=el.getAttribute('data-tooltip'); place(el); },500); });+      document.addEventListener('mouseout',e=>{ if(e.target.closest('[data-tooltip]')===cur){ clearTimeout(timer); tt.classList.remove('show'); cur=null; } });+    })();++    function esc(s){ return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }+    function statHtml(n,k,tip){ return '<div class="stat" data-tooltip="'+esc(tip)+'"><div class="n">'+n+'</div><div class="k">'+k+'</div></div>'; }+    function issueHtml(cls,o){ return '<div class="issue '+cls+'"><span class="code">'+esc(o.code)+'</span><span>'+esc(o.message)+'</span></div>'; }++    let lastHash='';+    async function pollState(){+      try{+        const r=await fetch('state',{cache:'no-store'}); const s=await r.json();+        setConn(true);+        const hash=JSON.stringify(s);+        if(hash!==lastHash){ lastHash=hash; render(s); }+      }catch(e){ setConn(false); }+    }+    function setConn(live){ const c=document.getElementById('conn'); c.classList.toggle('live',live); document.getElementById('connText').textContent=live?'live':'offline'; }+    function render(s){+      const has=!!s.hasContent;+      document.getElementById('empty').style.display=has?'none':'';+      document.getElementById('content').style.display=has?'':'none';+      document.getElementById('subjectName').textContent=has?s.libraryName:'—';+      document.getElementById('subjectMeta').textContent=has?(s.lintPassed?'lint pass':((s.errors||[]).length+' err')):'';+      document.getElementById('sendto').style.display=has?'inline-flex':'none';+      if(!has) return;++      document.getElementById('libName').textContent=s.libraryName;+      const badge=document.getElementById('badge'), bt=document.getElementById('badgeText');+      badge.className='badge '+(s.lintPassed?'pass':'fail');+      bt.textContent=s.lintPassed?'Lint passed':(((s.errors||[]).length)+' error'+(((s.errors||[]).length)===1?'':'s'));+      badge.setAttribute('data-tooltip', s.lintPassed+        ? 'All Fusion-360 lint checks passed — symbols, packages and a complete deviceset with pin→pad connects are present.'+        : 'Lint failed — Fusion 360 would reject or mis-import this library. Fix the errors below and re-generate.');++      document.getElementById('counts').innerHTML =+        statHtml(s.symbolCount,'symbols','Schematic symbol definitions in the library (EAGLE layer 94).')++        statHtml(s.packageCount,'packages','PCB footprint/package definitions (pads + silkscreen).')++        statHtml(s.devicesetCount,'devicesets','Devicesets bind a symbol to a package with pin→pad connects — what Fusion actually places.')++        statHtml(s.pinCount,'pins','Total schematic pins across all symbols.')++        statHtml(s.padCount,'pads','Total SMD/through-hole pads across all packages.');++      const errs=s.errors||[], warns=s.warnings||[];+      const eS=document.getElementById('errSection'), wS=document.getElementById('warnSection'), cS=document.getElementById('cleanSection');+      eS.style.display=errs.length?'':'none';+      document.getElementById('errList').innerHTML=errs.map(o=>issueHtml('e',o)).join('');+      wS.style.display=warns.length?'':'none';+      document.getElementById('warnList').innerHTML=warns.map(o=>issueHtml('w',o)).join('');+      cS.style.display=(!errs.length&&!warns.length)?'':'none';+    }+    setInterval(pollState,1000); pollState();++    // ── Send to desktop EDA tools ──+    let toastTimer=null;+    function showToast(msg,kind){ const t=document.getElementById('toast'); t.textContent=msg; t.className='show '+(kind||''); clearTimeout(toastTimer); toastTimer=setTimeout(()=>t.className=t.className.replace('show','').trim(),4200); }+    document.getElementById('sendto').addEventListener('click',async e=>{+      const b=e.target.closest('.sbtn'); if(!b) return;+      const target=b.dataset.target, label=b.textContent;+      const all=[...document.querySelectorAll('.sbtn')]; all.forEach(x=>x.disabled=true);+      showToast('Sending to '+label+'…');+      try{+        const r=await fetch('deliver',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({target})});+        const j=await r.json();+        if(j.needsBridge){ showBridgeModal(j); }+        else if(j.ok){ showToast('✓ '+(j.message||('Delivered to '+label)),'ok'); }+        else { showToast('✕ '+(j.message||j.error||('Could not deliver to '+label)),'err'); }+      }catch(err){ showToast('✕ Delivery failed: '+err,'err'); }+      finally{ all.forEach(x=>x.disabled=false); }+    });+    function showBridgeModal(j){+      let m=document.getElementById('bridgeModal');+      if(!m){ m=document.createElement('div'); m.id='bridgeModal'; document.body.appendChild(m); }+      m.innerHTML='<div class="bm-card"><div class="bm-icon">🌉</div>'+        +'<div class="bm-title">Build the '+(j.target||'')+' bridge</div>'+        +'<div class="bm-body">'+(j.message||'')+'</div>'+        +'<div class="bm-actions"><a class="bm-go" href="'+(j.contribute||'https://wiki.adom.inc')+'" target="_blank" rel="noopener">Contribute on the wiki →</a>'+        +'<button class="bm-dismiss">Got it</button></div></div>';+      m.classList.add('show');+      m.querySelector('.bm-dismiss').onclick=()=>m.classList.remove('show');+      m.onclick=e=>{if(e.target===m)m.classList.remove('show')};+    }++    async function evalLoop(){+      try{+        const r=await fetch('eval/pending',{cache:'no-store'}); const j=await r.json();+        if(j&&j.id){+          let result, err=null;+          try{ result=await (async()=>{ return eval(j.code); })(); }catch(e){ err=String(e&&e.stack||e); }+          await fetch('eval/'+j.id+'/result',{method:'POST',headers:{'Content-Type':'application/json'},+            body:JSON.stringify({result: err?undefined:safe(result), error:err})});+        }+      }catch(e){}+    }+    function safe(v){ try{ JSON.stringify(v); return v; }catch{ return String(v); } }+    setInterval(evalLoop,400); evalLoop();+  </script>+</body>+</html>
src/deliver.rsadded+103
@@ -0,0 +1,103 @@+//! "Send to" delivery — push the loaded `.lbr` to the user's desktop EDA tools+//! via the adom-desktop bridge. The EAGLE-family tools (Fusion 360 / Altium /+//! OrCAD) import the `.lbr` directly. KiCad doesn't read EAGLE libraries, so we+//! convert it to a native `.kicad_sym` first (the same import path as the CLI).+//! Best-effort: a clear message when the bridge is offline.++use serde_json::{json, Value};+use std::process::Command;++fn ad(verb: &str, payload: Value) -> Result<Value, String> {+    let out = Command::new("adom-desktop")+        .args([verb, &payload.to_string()])+        .output()+        .map_err(|e| format!("adom-desktop unreachable: {e}"))?;+    let txt = String::from_utf8_lossy(&out.stdout).to_string();+    Ok(serde_json::from_str::<Value>(&txt).unwrap_or_else(|_| json!({ "ok": out.status.success(), "raw": txt })))+}++fn looks_offline(v: &Value) -> bool {+    let s = v.to_string().to_lowercase();+    v.get("bridgeConnected").and_then(|b| b.as_bool()) == Some(false)+        || s.contains("not connected")+        || s.contains("no client")+        || s.contains("offline")+        || s.contains("timeout")+}++/// Deliver the `.lbr` to `target` ("kicad" | "fusion360" | "altium" | "orcad").+pub fn deliver(library_name: &str, lbr: &str, target: &str) -> Value {+    if lbr.is_empty() {+        return json!({ "ok": false, "error": "no library loaded" });+    }+    let dir = std::env::temp_dir().join(format!("adom-lbr-deliver-{}", std::process::id()));+    let _ = std::fs::create_dir_all(&dir);+    let safe = library_name.replace(['/', ' '], "_");++    // KiCad: convert the EAGLE library to a native .kicad_sym.+    if target == "kicad" {+        let parsed = match crate::parse_eagle_lbr(lbr) {+            Ok(p) => p,+            Err(e) => return json!({ "ok": false, "error": format!("could not convert .lbr for KiCad: {e}") }),+        };+        let kicad_sym = crate::generate_kicad_sym(&parsed);+        let sym_path = dir.join(format!("{safe}.kicad_sym"));+        if std::fs::write(&sym_path, &kicad_sym).is_err() {+            return json!({ "ok": false, "error": "could not write temp symbol" });+        }+        let send = ad("send_files", json!({ "filePaths": [sym_path.to_string_lossy()], "targetApp": "kicad", "destinationFolder": "symbols" }));+        let inst = ad("kicad_install_library", json!({ "libraryPath": sym_path.to_string_lossy(), "libraryName": library_name, "libraryType": "symbol" }));+        return summarize("KiCad", "symbol", &[send, inst]);+    }++    let (tool, app, folder) = match target {+        "fusion360" | "fusion" => ("Fusion 360", "fusion360", "fusion"),+        "altium" => ("Altium", "altium", "altium"),+        "orcad" => ("OrCAD", "orcad", "orcad"),+        _ => return json!({ "ok": false, "error": format!("unknown target {target}") }),+    };++    let lbr_path = dir.join(format!("{safe}.lbr"));+    if std::fs::write(&lbr_path, lbr).is_err() {+        return json!({ "ok": false, "error": "could not write temp .lbr" });+    }+    if app == "altium" || app == "orcad" {+        return bridge_not_built(tool, &lbr_path.to_string_lossy());+    }+    let send = ad("send_files", json!({ "filePaths": [lbr_path.to_string_lossy()], "targetApp": app, "destinationFolder": folder }));+    let win = send.as_ref().ok()+        .and_then(|s| s.get("destinationPaths").and_then(|p| p.as_array()).and_then(|a| a.first()).and_then(|x| x.as_str()).map(String::from))+        .unwrap_or_else(|| lbr_path.to_string_lossy().to_string());+    let mut steps = vec![send];+    if app == "fusion360" {+        steps.push(ad("fusion_open_lbr", json!({ "filePath": win })));+    }+    summarize(tool, "library (.lbr)", &steps)+}++/// Altium / OrCAD have no Adom Desktop bridge yet — the .lbr is ready, but invite+/// the user to build the bridge and contribute it on the wiki.+fn bridge_not_built(tool: &str, file: &str) -> Value {+    json!({+        "ok": false,+        "needsBridge": true,+        "target": tool,+        "file": file,+        "contribute": "https://wiki.adom.inc/adom/adom-desktop",+        "message": format!("Your EAGLE .lbr for {tool} is built and saved — but the {tool} ↔ Adom Desktop bridge isn't built yet. Want {tool} connectivity in the Adom ecosystem? Be the one to build the bridge and contribute it on the wiki.")+    })+}++fn summarize(tool: &str, what: &str, results: &[Result<Value, String>]) -> Value {+    for r in results {+        match r {+            Err(e) => return json!({ "ok": false, "target": tool, "error": e, "offline": e.contains("unreachable") }),+            Ok(v) if looks_offline(v) => {+                return json!({ "ok": false, "target": tool, "offline": true,+                    "message": format!("File generated, but Adom Desktop isn't connected to {tool}. Open Adom Desktop on your laptop and try again.") });+            }+            _ => {}+        }+    }+    json!({ "ok": true, "target": tool, "message": format!("Delivered {what} to {tool}.") })+}
src/main.rsadded+999
@@ -0,0 +1,999 @@+mod deliver;+mod server;++use clap::{Parser, Subcommand};+use serde::{Deserialize, Serialize};+use std::fs;+use std::path::PathBuf;+use std::process;++const VERSION: &str = include_str!("../VERSION");+// SAFETY NET — install subcommand fetches from wiki first; this is the offline fallback+const SKILL: &str = include_str!("../SKILL.md");++#[derive(Parser)]+#[command(name = "adom-lbr", version = VERSION.trim(), about = "EAGLE .lbr generator, linter, and validator for Fusion 360 Electronics")]+struct Cli {+    #[command(subcommand)]+    command: Commands,+}++#[derive(Subcommand)]+enum Commands {+    /// Generate a .lbr from a KiCad .kicad_sym (and optional .kicad_mod).+    /// If --sym is omitted but --fp is given, a symbol is synthesized from the+    /// footprint pads so footprint-only parts still form a placeable deviceset.+    Generate {+        /// Path to the .kicad_sym file (optional when --fp is given)+        #[arg(long)]+        sym: Option<PathBuf>,+        /// Path to the .kicad_mod footprint file (optional — generates a placeholder if omitted)+        #[arg(long)]+        fp: Option<PathBuf>,+        /// Output .lbr file path (default: <name>.lbr in the same directory)+        #[arg(short, long)]+        output: Option<PathBuf>,+        /// Symbol name override+        #[arg(long)]+        name: Option<String>,+    },+    /// Lint an existing .lbr file for Fusion 360 compatibility+    Lint {+        /// Path to the .lbr file+        path: PathBuf,+    },+    /// Validate a .lbr file has complete deviceset (symbol + package + connects)+    Validate {+        /// Path to the .lbr file+        path: PathBuf,+    },+    /// Import an EAGLE .lbr and convert to KiCad .kicad_sym+    Import {+        /// Path to the .lbr file+        path: PathBuf,+        /// Output .kicad_sym file path (default: <name>.kicad_sym)+        #[arg(short, long)]+        output: Option<PathBuf>,+    },+    /// Verify a .kicad_sym via service-kicad (headless KiCad validation)+    Check {+        /// Path to the .kicad_sym file+        path: PathBuf,+    },+    /// Run the live app server (AI-drivable: /state, /load, /eval, /console)+    Serve {+        /// Port to listen on+        #[arg(long, default_value_t = 8784)]+        port: u16,+        /// Optional .lbr to load into the view on startup+        #[arg(long)]+        file: Option<PathBuf>,+    },+    /// Install skill + bash completions+    Install,+    /// Check health+    Health,+    /// Generate shell completions+    Completions {+        #[arg(value_enum)]+        shell: clap_complete::Shell,+    },+}++// ── KiCad .kicad_sym parser ──++#[derive(Debug, Serialize, Deserialize)]+struct KicadPin {+    name: String,+    number: String,+    pin_type: String,+    x: f64,+    y: f64,+    angle: u32,+    length: f64,+}++#[derive(Debug, Serialize, Deserialize)]+struct KicadBody {+    left: f64,+    top: f64,+    right: f64,+    bottom: f64,+}++#[derive(Debug, Serialize, Deserialize)]+struct GroupLabel {+    text: String,+    x: f64,+    y: f64,+}++#[derive(Debug, Serialize, Deserialize)]+struct ParsedSymbol {+    name: String,+    reference: String,+    value: String,+    footprint: String,+    description: String,+    pins: Vec<KicadPin>,+    body: Option<KicadBody>,+    group_labels: Vec<GroupLabel>,+}++fn parse_kicad_sym(content: &str, name_override: Option<&str>) -> Result<ParsedSymbol, String> {+    let name = if let Some(n) = name_override {+        n.to_string()+    } else {+        let re = regex_lite::Regex::new(r#"\(symbol "([^"]+)""#).unwrap();+        re.captures(content)+            .and_then(|c| c.get(1))+            .map(|m| m.as_str().to_string())+            .ok_or("Cannot find symbol name in .kicad_sym")?+    };++    let mut pins = Vec::new();+    // The pin angle may be an int (`180`) or a float (`180.0`) depending on the+    // KiCad version that wrote the symbol; accept both. The shape/electrical+    // tokens may also be hyphenated (e.g. `power_in`, `no_connect`).+    let pin_re = regex_lite::Regex::new(+        r#"(?s)\(pin\s+([\w-]+)\s+[\w-]+\s+\(at\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\)\s+\(length\s+([-\d.]+)\).*?\(name\s+"([^"]*)".*?\(number\s+"([^"]*)""#+    ).unwrap();++    for cap in pin_re.captures_iter(content) {+        pins.push(KicadPin {+            pin_type: cap[1].to_string(),+            x: cap[2].parse().unwrap_or(0.0),+            y: cap[3].parse().unwrap_or(0.0),+            angle: cap[4].parse::<f64>().unwrap_or(0.0).round() as u32,+            length: cap[5].parse().unwrap_or(2.54),+            name: cap[6].to_string(),+            number: cap[7].to_string(),+        });+    }++    let body = {+        let rect_re = regex_lite::Regex::new(+            r#"\(rectangle\s+\(start\s+([-\d.]+)\s+([-\d.]+)\)\s+\(end\s+([-\d.]+)\s+([-\d.]+)\)"#+        ).unwrap();+        rect_re.captures(content).map(|c| KicadBody {+            left: c[1].parse().unwrap_or(0.0),+            top: c[2].parse().unwrap_or(0.0),+            right: c[3].parse().unwrap_or(0.0),+            bottom: c[4].parse().unwrap_or(0.0),+        })+    };++    let mut group_labels = Vec::new();+    let text_re = regex_lite::Regex::new(+        r#"\(text "([^"]+)"\s+\(at\s+([-\d.]+)\s+([-\d.]+)"#+    ).unwrap();+    for cap in text_re.captures_iter(content) {+        group_labels.push(GroupLabel {+            text: cap[1].to_string(),+            x: cap[2].parse().unwrap_or(0.0),+            y: cap[3].parse().unwrap_or(0.0),+        });+    }++    let prop = |key: &str| -> String {+        let re = regex_lite::Regex::new(&format!(r#"\(property\s+"{}"\s+"([^"]*)""#, key)).unwrap();+        re.captures(content)+            .and_then(|c| c.get(1))+            .map(|m| m.as_str().to_string())+            .unwrap_or_default()+    };++    Ok(ParsedSymbol {+        name,+        reference: prop("Reference"),+        value: prop("Value"),+        footprint: prop("Footprint"),+        description: prop("Description"),+        pins,+        body,+        group_labels,+    })+}++// ── EAGLE .lbr parser (import) ──++fn parse_eagle_lbr(content: &str) -> Result<ParsedSymbol, String> {+    let pin_re = regex_lite::Regex::new(+        r#"<pin name="([^"]+)" x="([^"]+)" y="([^"]+)" length="([^"]+)" direction="([^"]+)"([^/]*)/>"#+    ).unwrap();++    let sym_name_re = regex_lite::Regex::new(r#"<symbol name="([^"]+)">"#).unwrap();+    let name = sym_name_re.captures(content)+        .and_then(|c| c.get(1))+        .map(|m| m.as_str().to_string())+        .ok_or("Cannot find <symbol name=\"...\"> in .lbr")?;++    let desc_re = regex_lite::Regex::new(r#"<description>([^<]*)</description>"#).unwrap();+    let description = desc_re.captures(content)+        .and_then(|c| c.get(1))+        .map(|m| m.as_str().to_string())+        .unwrap_or_default()+        .replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">")+        .replace("&quot;", "\"").replace("&apos;", "'");++    let prefix_re = regex_lite::Regex::new(r#"<deviceset[^>]*prefix="([^"]+)""#).unwrap();+    let reference = prefix_re.captures(content)+        .and_then(|c| c.get(1))+        .map(|m| m.as_str().to_string())+        .unwrap_or_else(|| "U".to_string());++    let rect_re = regex_lite::Regex::new(+        r#"<wire x1="([^"]+)" y1="([^"]+)" x2="([^"]+)" y2="([^"]+)" width="[^"]*" layer="94"/>"#+    ).unwrap();++    let mut min_x = f64::MAX;+    let mut min_y = f64::MAX;+    let mut max_x = f64::MIN;+    let mut max_y = f64::MIN;+    let mut has_body = false;+    for cap in rect_re.captures_iter(content) {+        has_body = true;+        let x1: f64 = cap[1].parse().unwrap_or(0.0);+        let y1: f64 = cap[2].parse().unwrap_or(0.0);+        let x2: f64 = cap[3].parse().unwrap_or(0.0);+        let y2: f64 = cap[4].parse().unwrap_or(0.0);+        min_x = min_x.min(x1).min(x2);+        min_y = min_y.min(y1).min(y2);+        max_x = max_x.max(x1).max(x2);+        max_y = max_y.max(y1).max(y2);+    }++    let body = if has_body {+        Some(KicadBody { left: min_x, top: max_y, right: max_x, bottom: min_y })+    } else {+        None+    };++    let connect_re = regex_lite::Regex::new(+        r#"<connect gate="[^"]*" pin="([^"]+)" pad="([^"]+)"/>"#+    ).unwrap();+    let mut pin_to_pad: std::collections::HashMap<String, String> = std::collections::HashMap::new();+    for cap in connect_re.captures_iter(content) {+        pin_to_pad.insert(cap[1].to_string(), cap[2].to_string());+    }++    let length_val = |kw: &str| -> f64 {+        match kw {+            "point" => 0.0,+            "short" => 2.54,+            "middle" => 5.08,+            "long" => 7.62,+            _ => 2.54,+        }+    };++    let eagle_to_kicad_dir = |d: &str| -> &'static str {+        match d {+            "pwr" => "power_in",+            "in" => "input",+            "out" => "output",+            "io" => "bidirectional",+            "pas" => "passive",+            "nc" => "unconnected",+            "hiz" => "tri_state",+            "oc" => "open_collector",+            _ => "bidirectional",+        }+    };++    let rot_re = regex_lite::Regex::new(r#"rot="R(\d+)""#).unwrap();++    let mut pins = Vec::new();+    for cap in pin_re.captures_iter(content) {+        let pin_name_raw = cap[1].to_string();+        let pin_name = pin_name_raw.replace("@2", "").replace("@3", "")+            .replace("@4", "").replace("@5", "").replace("@6", "")+            .replace("@7", "").replace("@8", "").replace("@9", "");++        let pad_number = pin_to_pad.get(&pin_name_raw)+            .cloned()+            .unwrap_or_else(|| "0".to_string());++        let angle = rot_re.captures(&cap[6])+            .and_then(|c| c.get(1))+            .and_then(|m| m.as_str().parse::<u32>().ok())+            .unwrap_or(0);++        pins.push(KicadPin {+            name: pin_name,+            number: pad_number,+            pin_type: eagle_to_kicad_dir(&cap[5]).to_string(),+            x: cap[2].parse().unwrap_or(0.0),+            y: cap[3].parse().unwrap_or(0.0),+            angle,+            length: length_val(&cap[4]),+        });+    }++    Ok(ParsedSymbol {+        value: name.clone(),+        name,+        reference,+        footprint: String::new(),+        description,+        pins,+        body,+        group_labels: Vec::new(),+    })+}++// ── KiCad .kicad_sym generator ──++fn generate_kicad_sym(sym: &ParsedSymbol) -> String {+    let mut s = String::with_capacity(4096);+    s.push_str("(kicad_symbol_lib\n  (version 20231120)\n  (generator \"adom-lbr\")\n");+    s.push_str(&format!("  (symbol \"{}\"\n", sym.name));+    s.push_str("    (pin_names (offset 1.016))\n");+    s.push_str("    (exclude_from_sim no)\n");+    s.push_str("    (in_bom yes)\n    (on_board yes)\n");++    s.push_str(&format!("    (property \"Reference\" \"{}\"\n      (at 0 2.54 0)\n      (effects (font (size 1.27 1.27)))\n    )\n", sym.reference));+    s.push_str(&format!("    (property \"Value\" \"{}\"\n      (at 0 -2.54 0)\n      (effects (font (size 1.27 1.27)))\n    )\n", sym.name));+    s.push_str(&format!("    (property \"Footprint\" \"{}\"\n      (at 0 0 0)\n      (effects (font (size 1.27 1.27)) (hide yes))\n    )\n",+        sym.footprint.replace('"', "\\\"")+    ));+    s.push_str("    (property \"Datasheet\" \"\"\n      (at 0 0 0)\n      (effects (font (size 1.27 1.27)) (hide yes))\n    )\n");+    if !sym.description.is_empty() {+        s.push_str(&format!("    (property \"Description\" \"{}\"\n      (at 0 0 0)\n      (effects (font (size 1.27 1.27)) (hide yes))\n    )\n",+            sym.description.replace('"', "\\\"")+        ));+    }++    s.push_str(&format!("    (symbol \"{}_0_{}\"\n", sym.name, 1));++    if let Some(body) = &sym.body {+        s.push_str(&format!("      (rectangle (start {} {}) (end {} {})\n        (stroke (width 0.254) (type default))\n        (fill (type background))\n      )\n",+            body.left, body.top, body.right, body.bottom+        ));+    }++    for pin in &sym.pins {+        let kicad_angle = match pin.angle {+            0 => 0,+            90 => 90,+            180 => 180,+            270 => 270,+            _ => 0,+        };+        s.push_str(&format!(+            "      (pin {} line (at {} {} {}) (length {}) (name \"{}\") (number \"{}\"))\n",+            pin.pin_type, pin.x, pin.y, kicad_angle, pin.length,+            pin.name.replace('"', "\\\""),+            pin.number.replace('"', "\\\"")+        ));+    }++    s.push_str("    )\n");+    s.push_str("  )\n)\n");+    s+}++// ── EAGLE .lbr generator ──++fn esc_xml(s: &str) -> String {+    s.replace('&', "&amp;")+        .replace('<', "&lt;")+        .replace('>', "&gt;")+        .replace('"', "&quot;")+        .replace('\'', "&apos;")+}++fn pin_direction(kicad_type: &str) -> &'static str {+    match kicad_type {+        "power_in" => "pwr",+        "power_out" => "out",+        "input" => "in",+        "output" => "out",+        "bidirectional" => "io",+        "passive" => "pas",+        "unconnected" => "nc",+        "tri_state" => "hiz",+        "open_collector" | "open_emitter" => "oc",+        _ => "io",+    }+}++fn pin_rotation(angle: u32) -> &'static str {+    match angle {+        180 => " rot=\"R180\"",+        90 => " rot=\"R90\"",+        270 => " rot=\"R270\"",+        _ => "",+    }+}++fn pin_length_keyword(len: f64) -> &'static str {+    if len <= 0.0 { "point" }+    else if len <= 2.54 { "short" }+    else if len <= 5.08 { "middle" }+    else { "long" }+}++fn dedup_pin_names(pins: &[KicadPin]) -> Vec<String> {+    let mut counts: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();+    for pin in pins {+        *counts.entry(&pin.name).or_insert(0) += 1;+    }+    let mut seen: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();+    pins.iter().map(|pin| {+        let total = counts[pin.name.as_str()];+        if total == 1 {+            pin.name.clone()+        } else {+            let n = seen.entry(&pin.name).or_insert(0);+            *n += 1;+            if *n == 1 {+                pin.name.clone()+            } else {+                format!("{}@{}", pin.name, n)+            }+        }+    }).collect()+}++/// The first `(footprint "NAME" …)` name in a `.kicad_mod`, if any.+fn first_footprint_name(km: &str) -> Option<String> {+    let re = regex_lite::Regex::new(r#"\(footprint\s+"([^"]+)""#).unwrap();+    re.captures(km).and_then(|c| c.get(1)).map(|m| m.as_str().to_string())+}++/// Synthesize a placeable symbol from a `.kicad_mod`'s pads when there is no+/// `.kicad_sym`. One symbol pin per unique pad number, balanced left/right, so+/// the resulting EAGLE deviceset has the correct pin/pad names and connects.+fn synth_symbol_from_mod(km: &str, name: &str) -> ParsedSymbol {+    // Pad numbers in document order, de-duplicated (thru-hole pairs share a no.).+    // Pad ids may be quoted ("A1") or bare (1); skip mechanical pads ("").+    let pad_re = regex_lite::Regex::new(r#"\(pad\s+(?:"([^"]*)"|(\S+))"#).unwrap();+    let mut nums: Vec<String> = Vec::new();+    for c in pad_re.captures_iter(km) {+        let n = c.get(1).or_else(|| c.get(2)).map(|m| m.as_str().to_string()).unwrap_or_default();+        if !n.is_empty() && !nums.contains(&n) {+            nums.push(n);+        }+    }+    let count = nums.len().max(1);+    let half = (count + 1) / 2;+    let body_h = (half as f64) * 2.54 + 2.54;+    let pins: Vec<KicadPin> = nums.iter().enumerate().map(|(i, num)| {+        let (x, y, angle) = if i < half {+            (-12.7, (half as f64 / 2.0 - i as f64) * 2.54, 0)+        } else {+            (12.7, (half as f64 / 2.0 - (i - half) as f64) * 2.54, 180)+        };+        KicadPin {+            name: num.clone(),+            number: num.clone(),+            pin_type: "passive".into(),+            x, y, angle, length: 2.54,+        }+    }).collect();+    ParsedSymbol {+        name: name.to_string(),+        reference: "U".into(),+        value: name.to_string(),+        footprint: name.to_string(),+        description: format!("{name} (symbol synthesized from footprint pads)"),+        pins,+        body: Some(KicadBody { left: -7.62, top: body_h / 2.0, right: 7.62, bottom: -body_h / 2.0 }),+        group_labels: Vec::new(),+    }+}++fn generate_lbr(sym: &ParsedSymbol, _fp_content: Option<&str>) -> String {+    let mut xml = String::with_capacity(8192);++    xml.push_str("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");+    xml.push_str("<!DOCTYPE eagle SYSTEM \"eagle.dtd\">\n");+    xml.push_str("<eagle version=\"9.6.2\">\n<drawing>\n");+    xml.push_str("<settings><setting alwaysvectorfont=\"no\"/></settings>\n");+    xml.push_str("<grid distance=\"0.1\" unitdist=\"inch\" unit=\"inch\" style=\"lines\" multiple=\"1\" display=\"no\" altdistance=\"0.01\" altunitdist=\"inch\" altunit=\"inch\"/>\n");++    // Layers+    xml.push_str(EAGLE_LAYERS);++    xml.push_str("<library>\n");+    xml.push_str(&format!("<description>{}</description>\n", esc_xml(&sym.description)));++    // Packages section — generate a placeholder SMD package from pin positions+    xml.push_str("<packages>\n");+    xml.push_str(&format!("<package name=\"{}\">\n", esc_xml(&sym.name)));+    xml.push_str(&format!("  <description>{} placeholder footprint</description>\n", esc_xml(&sym.name)));++    // Generate SMD pads from pin numbers+    let pad_size = 0.5;+    let pitch = 0.5;+    let pin_count = sym.pins.len();+    let half = (pin_count + 1) / 2;++    for (i, pin) in sym.pins.iter().enumerate() {+        let (px, py) = if i < half {+            // Left column+            (-(pin_count as f64 * pitch / 4.0), (half as f64 / 2.0 - i as f64) * pitch)+        } else {+            // Right column+            (pin_count as f64 * pitch / 4.0, (half as f64 / 2.0 - (i - half) as f64) * pitch)+        };+        xml.push_str(&format!(+            "  <smd name=\"{}\" x=\"{:.3}\" y=\"{:.3}\" dx=\"{:.2}\" dy=\"{:.2}\" layer=\"1\"/>\n",+            esc_xml(&pin.number), px, py, pad_size, pad_size+        ));+    }++    // Package outline+    let body_w = pin_count as f64 * pitch / 2.0 + 1.0;+    let body_h = half as f64 * pitch + 1.0;+    xml.push_str(&format!(+        "  <wire x1=\"{:.3}\" y1=\"{:.3}\" x2=\"{:.3}\" y2=\"{:.3}\" width=\"0.127\" layer=\"21\"/>\n",+        -body_w / 2.0, body_h / 2.0, body_w / 2.0, body_h / 2.0+    ));+    xml.push_str(&format!(+        "  <wire x1=\"{:.3}\" y1=\"{:.3}\" x2=\"{:.3}\" y2=\"{:.3}\" width=\"0.127\" layer=\"21\"/>\n",+        body_w / 2.0, body_h / 2.0, body_w / 2.0, -body_h / 2.0+    ));+    xml.push_str(&format!(+        "  <wire x1=\"{:.3}\" y1=\"{:.3}\" x2=\"{:.3}\" y2=\"{:.3}\" width=\"0.127\" layer=\"21\"/>\n",+        body_w / 2.0, -body_h / 2.0, -body_w / 2.0, -body_h / 2.0+    ));+    xml.push_str(&format!(+        "  <wire x1=\"{:.3}\" y1=\"{:.3}\" x2=\"{:.3}\" y2=\"{:.3}\" width=\"0.127\" layer=\"21\"/>\n",+        -body_w / 2.0, -body_h / 2.0, -body_w / 2.0, body_h / 2.0+    ));++    xml.push_str(&format!("  <text x=\"0\" y=\"{:.3}\" size=\"1.27\" layer=\"25\" align=\"bottom-center\">&gt;NAME</text>\n", body_h / 2.0 + 0.5));+    xml.push_str(&format!("  <text x=\"0\" y=\"{:.3}\" size=\"1.27\" layer=\"27\" align=\"top-center\">&gt;VALUE</text>\n", -body_h / 2.0 - 0.5));++    xml.push_str("</package>\n");+    xml.push_str("</packages>\n");++    // Symbols section+    xml.push_str("<symbols>\n");+    xml.push_str(&format!("<symbol name=\"{}\">\n", esc_xml(&sym.name)));++    if let Some(body) = &sym.body {+        xml.push_str(&format!("  <wire x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" width=\"0.254\" layer=\"94\"/>\n", body.left, body.top, body.right, body.top));+        xml.push_str(&format!("  <wire x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" width=\"0.254\" layer=\"94\"/>\n", body.right, body.top, body.right, body.bottom));+        xml.push_str(&format!("  <wire x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" width=\"0.254\" layer=\"94\"/>\n", body.right, body.bottom, body.left, body.bottom));+        xml.push_str(&format!("  <wire x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" width=\"0.254\" layer=\"94\"/>\n", body.left, body.bottom, body.left, body.top));+    }++    let name_y = sym.body.as_ref().map(|b| b.top + 2.54).unwrap_or(10.0);+    let value_y = sym.body.as_ref().map(|b| b.bottom - 2.54).unwrap_or(-10.0);+    xml.push_str(&format!("  <text x=\"0\" y=\"{}\" size=\"1.27\" layer=\"95\" align=\"bottom-center\">&gt;NAME</text>\n", name_y));+    xml.push_str(&format!("  <text x=\"0\" y=\"{}\" size=\"1.27\" layer=\"96\" align=\"top-center\">&gt;VALUE</text>\n", value_y));++    let deduped_names = dedup_pin_names(&sym.pins);+    for (i, pin) in sym.pins.iter().enumerate() {+        xml.push_str(&format!(+            "  <pin name=\"{}\" x=\"{}\" y=\"{}\" length=\"{}\" direction=\"{}\"{}/>\n",+            esc_xml(&deduped_names[i]), pin.x, pin.y,+            pin_length_keyword(pin.length),+            pin_direction(&pin.pin_type),+            pin_rotation(pin.angle)+        ));+    }++    let body_center_x = sym.body.as_ref().map(|b| (b.left + b.right) / 2.0).unwrap_or(0.0);+    let body_left = sym.body.as_ref().map(|b| b.left).unwrap_or(-10.0);+    let body_right = sym.body.as_ref().map(|b| b.right).unwrap_or(10.0);+    for label in &sym.group_labels {+        let (lx, align) = if label.x > body_center_x {+            (body_right - 1.0, " align=\"right\"")+        } else {+            (body_left + 1.0, "")+        };+        xml.push_str(&format!(+            "  <text x=\"{}\" y=\"{}\" size=\"0.762\" layer=\"94\" font=\"vector\" ratio=\"15\"{}>{}</text>\n",+            lx, label.y, align, esc_xml(&label.text)+        ));+    }++    xml.push_str("</symbol>\n");+    xml.push_str("</symbols>\n");++    // Deviceset with COMPLETE device (package + connects)+    let ref_prefix = if sym.reference.is_empty() { "U" } else { &sym.reference };+    xml.push_str("<devicesets>\n");+    xml.push_str(&format!("<deviceset name=\"{}\" prefix=\"{}\">\n", esc_xml(&sym.name), esc_xml(ref_prefix)));+    xml.push_str(&format!("  <description>{}</description>\n", esc_xml(&sym.description)));+    xml.push_str("  <gates>\n");+    xml.push_str(&format!("    <gate name=\"G$1\" symbol=\"{}\" x=\"0\" y=\"0\"/>\n", esc_xml(&sym.name)));+    xml.push_str("  </gates>\n");+    xml.push_str("  <devices>\n");+    xml.push_str(&format!("    <device name=\"\" package=\"{}\">\n", esc_xml(&sym.name)));+    xml.push_str("      <connects>\n");++    // Generate connect mappings: gate pin name → pad number+    for (i, pin) in sym.pins.iter().enumerate() {+        xml.push_str(&format!(+            "        <connect gate=\"G$1\" pin=\"{}\" pad=\"{}\"/>\n",+            esc_xml(&deduped_names[i]), esc_xml(&pin.number)+        ));+    }++    xml.push_str("      </connects>\n");+    xml.push_str("      <technologies><technology name=\"\"/></technologies>\n");+    xml.push_str("    </device>\n");+    xml.push_str("  </devices>\n");+    xml.push_str("</deviceset>\n");+    xml.push_str("</devicesets>\n");++    xml.push_str("</library>\n</drawing>\n</eagle>");+    xml+}++// ── Linter ──++#[derive(Debug, Serialize)]+pub(crate) struct LintResult {+    pub passed: bool,+    pub errors: Vec<LintError>,+    pub warnings: Vec<LintError>,+}++#[derive(Debug, Serialize)]+pub(crate) struct LintError {+    pub code: String,+    pub message: String,+}++pub(crate) fn lint_lbr(content: &str) -> LintResult {+    let mut errors = Vec::new();+    let mut warnings = Vec::new();++    if !content.contains("<packages>") || !content.contains("</packages>") {+        errors.push(LintError { code: "missing-packages".into(), message: "No <packages> section found".into() });+    }+    if !content.contains("<symbols>") || !content.contains("</symbols>") {+        errors.push(LintError { code: "missing-symbols".into(), message: "No <symbols> section found".into() });+    }+    if !content.contains("<devicesets>") || !content.contains("</devicesets>") {+        errors.push(LintError { code: "missing-devicesets".into(), message: "No <devicesets> section found".into() });+    }++    // Check for empty packages+    if content.contains("<packages></packages>") || content.contains("<packages/>") {+        errors.push(LintError { code: "empty-packages".into(), message: "Packages section is empty — Fusion will show 'No Components'".into() });+    }++    // Check for empty connects+    if content.contains("<connects/>") || content.contains("<connects></connects>") {+        errors.push(LintError { code: "empty-connects".into(), message: "Device has no pin-to-pad connect mappings — Fusion cannot use this component".into() });+    }++    // Check device has a package attribute+    if content.contains("<device name=\"\">") && !content.contains("package=") {+        errors.push(LintError { code: "device-no-package".into(), message: "Device has no package attribute — Fusion needs a package to show the component".into() });+    }++    // Check for duplicate pin names (EAGLE requires unique)+    let pin_re = regex_lite::Regex::new(r#"<pin name="([^"]+)""#).unwrap();+    let mut pin_names = std::collections::HashMap::new();+    for cap in pin_re.captures_iter(content) {+        *pin_names.entry(cap[1].to_string()).or_insert(0u32) += 1;+    }+    for (name, count) in &pin_names {+        if *count > 1 {+            errors.push(LintError {+                code: "duplicate-pin-name".into(),+                message: format!("Pin name '{}' appears {} times — EAGLE requires unique pin names (use @suffix for duplicates)", name, count),+            });+        }+    }++    // Check pad names match pin numbers in connects+    let connect_re = regex_lite::Regex::new(r#"<connect[^>]*pad="([^"]+)""#).unwrap();+    let smd_re = regex_lite::Regex::new(r#"<smd name="([^"]+)""#).unwrap();+    let pad_names: std::collections::HashSet<_> = smd_re.captures_iter(content).map(|c| c[1].to_string()).collect();+    for cap in connect_re.captures_iter(content) {+        let pad = &cap[1];+        if !pad_names.contains(pad) && !pad_names.is_empty() {+            warnings.push(LintError {+                code: "connect-pad-missing".into(),+                message: format!("Connect references pad '{}' which doesn't exist in the package", pad),+            });+        }+    }++    // Check XML is well-formed+    if content.matches('<').count() != content.matches('>').count() {+        errors.push(LintError { code: "malformed-xml".into(), message: "Unbalanced XML angle brackets".into() });+    }++    // Fusion-specific checks+    if !content.contains("layer=\"94\"") {+        warnings.push(LintError { code: "no-symbol-layer".into(), message: "No elements on layer 94 (Symbols) — the symbol body may be invisible".into() });+    }++    LintResult {+        passed: errors.is_empty(),+        errors,+        warnings,+    }+}++// ── Layers constant ──++const EAGLE_LAYERS: &str = r#"<layers>+<layer number="1" name="Top" color="4" fill="1" visible="yes" active="yes"/>+<layer number="16" name="Bottom" color="1" fill="1" visible="yes" active="yes"/>+<layer number="17" name="Pads" color="2" fill="1" visible="yes" active="yes"/>+<layer number="18" name="Vias" color="2" fill="1" visible="yes" active="yes"/>+<layer number="19" name="Unrouted" color="6" fill="1" visible="yes" active="yes"/>+<layer number="20" name="Dimension" color="15" fill="1" visible="yes" active="yes"/>+<layer number="21" name="tPlace" color="7" fill="1" visible="yes" active="yes"/>+<layer number="22" name="bPlace" color="7" fill="1" visible="yes" active="yes"/>+<layer number="23" name="tOrigins" color="15" fill="1" visible="yes" active="yes"/>+<layer number="24" name="bOrigins" color="15" fill="1" visible="yes" active="yes"/>+<layer number="25" name="tNames" color="7" fill="1" visible="yes" active="yes"/>+<layer number="26" name="bNames" color="7" fill="1" visible="yes" active="yes"/>+<layer number="27" name="tValues" color="7" fill="1" visible="yes" active="yes"/>+<layer number="28" name="bValues" color="7" fill="1" visible="yes" active="yes"/>+<layer number="29" name="tStop" color="7" fill="3" visible="no" active="yes"/>+<layer number="30" name="bStop" color="7" fill="6" visible="no" active="yes"/>+<layer number="31" name="tCream" color="7" fill="4" visible="no" active="yes"/>+<layer number="32" name="bCream" color="7" fill="5" visible="no" active="yes"/>+<layer number="33" name="tFinish" color="6" fill="3" visible="no" active="yes"/>+<layer number="34" name="bFinish" color="6" fill="6" visible="no" active="yes"/>+<layer number="35" name="tGlue" color="7" fill="4" visible="no" active="yes"/>+<layer number="36" name="bGlue" color="7" fill="5" visible="no" active="yes"/>+<layer number="37" name="tTest" color="7" fill="1" visible="no" active="yes"/>+<layer number="38" name="bTest" color="7" fill="1" visible="no" active="yes"/>+<layer number="39" name="tKeepout" color="4" fill="11" visible="no" active="yes"/>+<layer number="40" name="bKeepout" color="1" fill="11" visible="no" active="yes"/>+<layer number="41" name="tRestrict" color="4" fill="10" visible="no" active="yes"/>+<layer number="42" name="bRestrict" color="1" fill="10" visible="no" active="yes"/>+<layer number="43" name="vRestrict" color="2" fill="10" visible="no" active="yes"/>+<layer number="44" name="Drills" color="7" fill="1" visible="no" active="yes"/>+<layer number="45" name="Holes" color="7" fill="1" visible="no" active="yes"/>+<layer number="46" name="Milling" color="3" fill="1" visible="no" active="yes"/>+<layer number="47" name="Measures" color="7" fill="1" visible="no" active="yes"/>+<layer number="48" name="Document" color="7" fill="1" visible="yes" active="yes"/>+<layer number="49" name="Reference" color="7" fill="1" visible="yes" active="yes"/>+<layer number="91" name="Nets" color="2" fill="1" visible="yes" active="yes"/>+<layer number="92" name="Busses" color="1" fill="1" visible="yes" active="yes"/>+<layer number="93" name="Pins" color="2" fill="1" visible="no" active="yes"/>+<layer number="94" name="Symbols" color="4" fill="1" visible="yes" active="yes"/>+<layer number="95" name="Names" color="7" fill="1" visible="yes" active="yes"/>+<layer number="96" name="Values" color="7" fill="1" visible="yes" active="yes"/>+<layer number="97" name="Info" color="7" fill="1" visible="yes" active="yes"/>+<layer number="98" name="Guide" color="6" fill="1" visible="yes" active="yes"/>+</layers>+"#;++// ── Main ──++fn main() {+    let cli = Cli::parse();++    match cli.command {+        Commands::Generate { sym, fp, output, name } => {+            let fp_content = fp.as_ref().map(|p| {+                fs::read_to_string(p).unwrap_or_else(|e| {+                    eprintln!("ERROR: Cannot read footprint {}: {}", p.display(), e);+                    process::exit(1);+                })+            });++            let parsed = match (&sym, &fp_content) {+                (Some(sym_path), _) => {+                    let sym_content = fs::read_to_string(sym_path).unwrap_or_else(|e| {+                        eprintln!("ERROR: Cannot read {}: {}", sym_path.display(), e);+                        eprintln!("Hint: Check the .kicad_sym path exists.");+                        process::exit(1);+                    });+                    parse_kicad_sym(&sym_content, name.as_deref()).unwrap_or_else(|e| {+                        eprintln!("ERROR: {}", e);+                        process::exit(1);+                    })+                }+                (None, Some(fp)) => {+                    // Footprint-only: synthesize a placeable symbol from the pads.+                    let syn_name = name.clone()+                        .or_else(|| first_footprint_name(fp))+                        .unwrap_or_else(|| "FOOTPRINT".to_string());+                    synth_symbol_from_mod(fp, &syn_name)+                }+                (None, None) => {+                    eprintln!("ERROR: Generate needs at least --sym or --fp.");+                    eprintln!("Hint: `adom-lbr generate --fp part.kicad_mod` synthesizes a symbol from the pads.");+                    process::exit(1);+                }+            };++            let out_default = sym.as_ref().or(fp.as_ref()).cloned().unwrap_or_else(|| PathBuf::from(&parsed.name));+            let lbr = generate_lbr(&parsed, fp_content.as_deref());++            // Lint before writing+            let lint = lint_lbr(&lbr);+            for err in &lint.errors {+                eprintln!("LINT ERROR: [{}] {}", err.code, err.message);+            }+            for warn in &lint.warnings {+                eprintln!("LINT WARN: [{}] {}", warn.code, warn.message);+            }++            let out_path = output.unwrap_or_else(|| {+                out_default.with_extension("lbr")+            });++            fs::write(&out_path, &lbr).unwrap_or_else(|e| {+                eprintln!("ERROR: Cannot write {}: {}", out_path.display(), e);+                process::exit(1);+            });++            println!("OK: Generated {} ({} pins, {} bytes)", out_path.display(), parsed.pins.len(), lbr.len());+            println!("HINT: Lint {} with {} error(s), {} warning(s)", if lint.passed { "PASSED" } else { "FAILED" }, lint.errors.len(), lint.warnings.len());++            if !lint.passed {+                process::exit(2);+            }+        }++        Commands::Lint { path } => {+            let content = fs::read_to_string(&path).unwrap_or_else(|e| {+                eprintln!("ERROR: Cannot read {}: {}", path.display(), e);+                eprintln!("Hint: Check the path exists. Use `ls` to verify.");+                process::exit(1);+            });++            let result = lint_lbr(&content);+            if result.passed {+                println!("OK: Lint passed with 0 error(s), {} warning(s)", result.warnings.len());+            } else {+                eprintln!("ERROR: Lint failed with {} error(s)", result.errors.len());+                for err in &result.errors {+                    eprintln!("  [{}] {}", err.code, err.message);+                }+                eprintln!("Hint: Fix the errors above, then re-run `adom-lbr lint {}`", path.display());+            }+            for warn in &result.warnings {+                eprintln!("WARN: [{}] {}", warn.code, warn.message);+            }+            // Also output JSON for programmatic consumers+            let json = serde_json::to_string_pretty(&result).unwrap();+            println!("{}", json);++            if !result.passed {+                process::exit(2);+            }+        }++        Commands::Validate { path } => {+            let content = fs::read_to_string(&path).unwrap_or_else(|e| {+                eprintln!("ERROR: Cannot read {}: {}", path.display(), e);+                process::exit(1);+            });++            let result = lint_lbr(&content);+            if result.passed {+                println!("OK: {} is a valid EAGLE .lbr with complete deviceset", path.display());+            } else {+                eprintln!("FAIL: {} has {} error(s)", path.display(), result.errors.len());+                for err in &result.errors {+                    eprintln!("  [{}] {}", err.code, err.message);+                }+                process::exit(2);+            }+        }++        Commands::Import { path, output } => {+            let content = fs::read_to_string(&path).unwrap_or_else(|e| {+                eprintln!("ERROR: Cannot read {}: {}", path.display(), e);+                process::exit(1);+            });++            let parsed = parse_eagle_lbr(&content).unwrap_or_else(|e| {+                eprintln!("ERROR: {}", e);+                process::exit(1);+            });++            let kicad_sym = generate_kicad_sym(&parsed);++            let out_path = output.unwrap_or_else(|| {+                path.with_extension("kicad_sym")+            });++            fs::write(&out_path, &kicad_sym).unwrap_or_else(|e| {+                eprintln!("ERROR: Cannot write {}: {}", out_path.display(), e);+                process::exit(1);+            });++            println!("OK: Imported {} → {} ({} pins)", path.display(), out_path.display(), parsed.pins.len());+            println!("HINT: Run 'adom-lbr check {}' to validate via service-kicad", out_path.display());+        }++        Commands::Check { path } => {+            let content = fs::read_to_string(&path).unwrap_or_else(|e| {+                eprintln!("ERROR: Cannot read {}: {}", path.display(), e);+                process::exit(1);+            });++            let mut errors: Vec<String> = Vec::new();++            if !content.contains("(kicad_symbol_lib") {+                errors.push("Missing (kicad_symbol_lib) header — not a valid .kicad_sym file".into());+            }+            if !content.contains("(symbol \"") {+                errors.push("No (symbol) definitions found".into());+            }++            let pin_count = content.matches("(pin ").count();+            if pin_count == 0 {+                errors.push("No pins found — symbol has no electrical connections".into());+            }++            if !content.contains("(property \"Reference\"") {+                errors.push("Missing Reference property".into());+            }+            if !content.contains("(property \"Value\"") {+                errors.push("Missing Value property".into());+            }++            // Check for duplicate pin names within each symbol+            let sym_re = regex_lite::Regex::new(r#"\(pin \w+ \w+ \(at [^)]+\) \(length [^)]+\) \(name "([^"]+)""#).unwrap();+            let mut pin_names: std::collections::HashMap<String, usize> = std::collections::HashMap::new();+            for cap in sym_re.captures_iter(&content) {+                *pin_names.entry(cap[1].to_string()).or_insert(0) += 1;+            }+            let dups: Vec<_> = pin_names.iter().filter(|(_, &c)| c > 1).collect();+            if !dups.is_empty() && !dups.iter().all(|(n, _)| n.contains('@') || n.as_str() == "~") {+                let dup_list: Vec<_> = dups.iter().filter(|(n, _)| !n.contains('@') && n.as_str() != "~").map(|(n, c)| format!("{} (x{})", n, c)).collect();+                if !dup_list.is_empty() {+                    // Duplicate pin names are valid in KiCad for power pins (DVDD, IOVDD, GND).+                    // Warn but don't error — EAGLE import will need @N suffixes.+                    eprintln!("WARN: Duplicate pin names (will need @N suffix for EAGLE): {}", dup_list.join(", "));+                }+            }++            if errors.is_empty() {+                println!("OK: {} validated — {} pins, {} unique pin names", path.display(), pin_count, pin_names.len());+            } else {+                eprintln!("ERROR: {} failed validation with {} error(s)", path.display(), errors.len());+                for e in &errors {+                    eprintln!("  {}", e);+                }+                eprintln!("Hint: Fix the errors above, then re-run `adom-lbr check {}`", path.display());+                process::exit(2);+            }+        }++        Commands::Serve { port, file } => {+            if let Some(f) = file {+                match fs::read_to_string(&f) {+                    Ok(content) => server::set_state(server::analyze_lbr(&content)),+                    Err(e) => {+                        eprintln!("ERROR: Cannot read {}: {}", f.display(), e);+                        process::exit(1);+                    }+                }+            }+            if let Err(e) = server::run(port) {+                eprintln!("ERROR: {e:#}");+                process::exit(1);+            }+        }++        Commands::Install => {+            let home = std::env::var("HOME").unwrap_or_else(|_| "/home/adom".to_string());+            let skill_dir = PathBuf::from(&home).join(".claude/skills/adom-lbr");+            fs::create_dir_all(&skill_dir).ok();+            fs::write(skill_dir.join("SKILL.md"), SKILL).ok();+            println!("Installed skill: adom-lbr -> {}", skill_dir.display());+            println!("Installed adom-lbr v{}", VERSION.trim());+        }++        Commands::Health => {+            println!("OK: adom-lbr v{}", VERSION.trim());+        }++        Commands::Completions { shell } => {+            use clap::CommandFactory;+            clap_complete::generate(shell, &mut Cli::command(), "adom-lbr", &mut std::io::stdout());+        }+    }+}
src/server.rsadded+251
@@ -0,0 +1,251 @@+//! adom-lbr live app server — the AI-drivable surface (app-creator §7/§7b/§8).+//! Same shell as adom-symbol/adom-footprint: GET / (UI), GET /state (poller),+//! POST /load (push an .lbr — raw XML, re-linted server-side), POST /eval ++//! GET /eval/pending + POST /eval/:id/result + GET /eval/:id, POST/GET /console,+//! POST /shutdown, GET /favicon.svg. No gallia, no Node.++use anyhow::Result;+use serde_json::{json, Value};+use std::collections::{HashMap, VecDeque};+use std::sync::atomic::{AtomicU64, Ordering};+use std::sync::Mutex;+use tiny_http::{Header, Method, Response, Server};++#[derive(Default, Clone)]+pub struct AppState {+    pub library_name: String,+    pub has_content: bool,+    pub lint_passed: bool,+    pub errors: Vec<(String, String)>,   // (code, message)+    pub warnings: Vec<(String, String)>, // (code, message)+    pub symbol_count: usize,+    pub package_count: usize,+    pub deviceset_count: usize,+    pub pin_count: usize,+    pub pad_count: usize,+    /// Raw `.lbr` XML retained for "Send to" delivery.+    pub lbr: String,+}++static STATE: Mutex<Option<AppState>> = Mutex::new(None);+static EVAL_PENDING: Mutex<VecDeque<(u64, String)>> = Mutex::new(VecDeque::new());+static EVAL_RESULTS: Mutex<Option<HashMap<u64, Value>>> = Mutex::new(None);+static CONSOLE: Mutex<Option<Vec<String>>> = Mutex::new(None);+static EVAL_ID: AtomicU64 = AtomicU64::new(1);++const APP_HTML: &str = include_str!("app.html");+const FAVICON: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5.5A1.5 1.5 0 0 1 5.5 4H9a1.5 1.5 0 0 1 1.5 1.5V20a1.5 1.5 0 0 0-1.5-1.5H5.5A1.5 1.5 0 0 1 4 17z"/><path d="M20 5.5A1.5 1.5 0 0 0 18.5 4H15a1.5 1.5 0 0 0-1.5 1.5V20a1.5 1.5 0 0 1 1.5-1.5h3.5a1.5 1.5 0 0 0 1.5-1.5z"/></svg>"##;++pub fn set_state(s: AppState) {+    *STATE.lock().unwrap() = Some(s);+}++/// Parse + lint an `.lbr` (EAGLE XML) into the displayable app state.+pub fn analyze_lbr(content: &str) -> AppState {+    let lint = crate::lint_lbr(content);+    let lib = extract_attr(content, "deviceset")+        .or_else(|| extract_attr(content, "symbol"))+        .or_else(|| extract_attr(content, "package"))+        .unwrap_or_else(|| "library".to_string());+    AppState {+        library_name: lib,+        has_content: true,+        lint_passed: lint.passed,+        errors: lint.errors.iter().map(|e| (e.code.clone(), e.message.clone())).collect(),+        warnings: lint.warnings.iter().map(|w| (w.code.clone(), w.message.clone())).collect(),+        symbol_count: content.matches("<symbol ").count(),+        package_count: content.matches("<package ").count(),+        deviceset_count: content.matches("<deviceset ").count(),+        pin_count: content.matches("<pin ").count(),+        pad_count: content.matches("<smd ").count() + content.matches("<pad ").count(),+        lbr: content.to_string(),+    }+}++/// First `name="…"` attribute of the first `<tag …>` element.+fn extract_attr(content: &str, tag: &str) -> Option<String> {+    let needle = format!("<{tag} ");+    let i = content.find(&needle)? + needle.len();+    let rest = &content[i..];+    let k = rest.find("name=\"")? + "name=\"".len();+    let j = rest[k..].find('"')? + k;+    Some(rest[k..j].to_string())+}++fn pairs_json(v: &[(String, String)]) -> Value {+    Value::Array(v.iter().map(|(c, m)| json!({ "code": c, "message": m })).collect())+}++fn state_json() -> String {+    match &*STATE.lock().unwrap() {+        Some(s) => json!({+            "libraryName": s.library_name,+            "hasContent": s.has_content,+            "lintPassed": s.lint_passed,+            "errors": pairs_json(&s.errors),+            "warnings": pairs_json(&s.warnings),+            "symbolCount": s.symbol_count,+            "packageCount": s.package_count,+            "devicesetCount": s.deviceset_count,+            "pinCount": s.pin_count,+            "padCount": s.pad_count,+        })+        .to_string(),+        None => "{}".to_string(),+    }+}++pub fn run(port: u16) -> Result<()> {+    *EVAL_RESULTS.lock().unwrap() = Some(HashMap::new());+    *CONSOLE.lock().unwrap() = Some(Vec::new());+    let server = Server::http(format!("0.0.0.0:{port}")).map_err(|e| anyhow::anyhow!("bind {port}: {e}"))?;+    eprintln!("[adom-lbr] serving on http://127.0.0.1:{port}");+    // app-creator §6a — best-effort HD port registration (internal/proxy-only).+    register_port(port, "adom-lbr", "EAGLE .lbr linter live app (AI-drivable)");++    for mut req in server.incoming_requests() {+        let url = req.url().split('?').next().unwrap_or("/").to_string();+        let method = req.method().clone();+        let mut body = String::new();+        if matches!(method, Method::Post) {+            let _ = req.as_reader().read_to_string(&mut body);+        }+        let resp = route(&method, &url, &body);+        let _ = req.respond(resp);+        if url == "/shutdown" && matches!(method, Method::Post) {+            break;+        }+    }+    unregister_port(port);+    Ok(())+}++/// Best-effort HD port registration (app-creator §6a / adom-port-hints).+/// Raw TCP so we add no HTTP-client dependency; never blocks serving, never+/// fails the app if HD is absent (outside Hydrogen Desktop).+fn register_port(port: u16, name: &str, reason: &str) {+    let body = format!(+        "{{\"port\":{port},\"action\":\"register\",\"name\":\"{name}\",\"reason\":\"{reason}\",\"expose\":\"internal\"}}"+    );+    std::thread::spawn(move || hd_post(&body));+}+fn unregister_port(port: u16) {+    hd_post(&format!("{{\"port\":{port},\"action\":\"unregister\"}}"));+}+fn hd_post(body: &str) {+    use std::io::Write;+    use std::net::{TcpStream, ToSocketAddrs};+    use std::time::Duration;+    let addr = match ("host.docker.internal", 47084u16).to_socket_addrs().ok().and_then(|mut a| a.next()) {+        Some(a) => a,+        None => return,+    };+    if let Ok(mut s) = TcpStream::connect_timeout(&addr, Duration::from_millis(800)) {+        let _ = s.set_write_timeout(Some(Duration::from_millis(800)));+        let req = format!(+            "POST /port-forward HTTP/1.1\r\nHost: host.docker.internal\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",+            body.len(),+            body+        );+        let _ = s.write_all(req.as_bytes());+    }+}++fn route(method: &Method, url: &str, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {+    match (method, url) {+        (Method::Get, "/") | (Method::Get, "/index.html") => html(APP_HTML),+        (Method::Get, "/favicon.svg") => svg(FAVICON),+        (Method::Get, "/state") => json_resp(&state_json()),+        (Method::Post, "/load") => {+            if let Ok(v) = serde_json::from_str::<Value>(body) {+                // Preferred: push raw .lbr XML and let the server lint it.+                if let Some(lbr) = v.get("lbr").and_then(|x| x.as_str()) {+                    set_state(analyze_lbr(lbr));+                }+            }+            json_resp("{\"ok\":true}")+        }+        (Method::Post, "/eval") => {+            let code = serde_json::from_str::<Value>(body)+                .ok()+                .and_then(|v| v.get("code").and_then(|c| c.as_str()).map(String::from))+                .unwrap_or_default();+            let id = EVAL_ID.fetch_add(1, Ordering::SeqCst);+            EVAL_PENDING.lock().unwrap().push_back((id, code));+            json_resp(&json!({ "id": id.to_string() }).to_string())+        }+        (Method::Get, "/eval/pending") => match EVAL_PENDING.lock().unwrap().pop_front() {+            Some((id, code)) => json_resp(&json!({ "id": id.to_string(), "code": code }).to_string()),+            None => json_resp("{}"),+        },+        (Method::Get, "/console") => {+            let log = CONSOLE.lock().unwrap().clone().unwrap_or_default();+            json_resp(&json!({ "lines": log }).to_string())+        }+        (Method::Post, "/console") => {+            if let Ok(v) = serde_json::from_str::<Value>(body) {+                let line = format!(+                    "[{}] {}",+                    v.get("level").and_then(|l| l.as_str()).unwrap_or("log"),+                    v.get("msg").and_then(|m| m.as_str()).unwrap_or("")+                );+                if let Some(buf) = CONSOLE.lock().unwrap().as_mut() {+                    buf.push(line);+                    let len = buf.len();+                    if len > 500 {+                        buf.drain(0..len - 500);+                    }+                }+            }+            json_resp("{\"ok\":true}")+        }+        (Method::Post, "/deliver") => {+            let v = serde_json::from_str::<Value>(body).unwrap_or(json!({}));+            let target = v.get("target").and_then(|x| x.as_str()).unwrap_or("kicad").to_string();+            let (name, lbr) = match &*STATE.lock().unwrap() {+                Some(s) => (s.library_name.clone(), s.lbr.clone()),+                None => (String::new(), String::new()),+            };+            json_resp(&crate::deliver::deliver(&name, &lbr, &target).to_string())+        }+        (Method::Post, "/shutdown") => json_resp("{\"ok\":true}"),+        _ if url.starts_with("/eval/") => {+            let rest = &url["/eval/".len()..];+            if let Some(id_str) = rest.strip_suffix("/result") {+                if matches!(method, Method::Post) {+                    if let (Ok(id), Ok(v)) = (id_str.parse::<u64>(), serde_json::from_str::<Value>(body)) {+                        if let Some(map) = EVAL_RESULTS.lock().unwrap().as_mut() {+                            map.insert(id, v);+                        }+                    }+                    return json_resp("{\"ok\":true}");+                }+            }+            if let Ok(id) = rest.parse::<u64>() {+                let map = EVAL_RESULTS.lock().unwrap();+                if let Some(r) = map.as_ref().and_then(|m| m.get(&id)) {+                    return json_resp(&json!({ "done": true, "result": r }).to_string());+                }+                return json_resp("{\"done\":false}");+            }+            not_found()+        }+        _ => not_found(),+    }+}++fn hdr(ct: &str) -> Header {+    Header::from_bytes(&b"Content-Type"[..], ct.as_bytes()).unwrap()+}+fn html(s: &str) -> Response<std::io::Cursor<Vec<u8>>> {+    Response::from_string(s).with_header(hdr("text/html; charset=utf-8"))+}+fn svg(s: &str) -> Response<std::io::Cursor<Vec<u8>>> {+    Response::from_string(s).with_header(hdr("image/svg+xml"))+}+fn json_resp(s: &str) -> Response<std::io::Cursor<Vec<u8>>> {+    Response::from_string(s).with_header(hdr("application/json"))+}+fn not_found() -> Response<std::io::Cursor<Vec<u8>>> {+    Response::from_string("{\"error\":\"not found\"}").with_status_code(404).with_header(hdr("application/json"))+}