app
Adom Chip Fetcher
Public Made by Adomby adom
Your whole parts library — manufacturer-grade chip CAD (symbol, footprint, 3D) one tap from your EDA tool.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994
use anyhow::Result;
use clap::{Parser, Subcommand};
use std::path::PathBuf;
use crate::{hints, import, inventory, library, pup, serve, sources, sourcing, thumbnails, validate};
const SKILL_MD: &str = include_str!("../SKILL.md");
// Playbooks — deep reference, loaded on demand by Claude when SKILL.md tells it which one to Read.
// Embedded at compile time so `adom-chip-fetcher install` deploys them alongside SKILL.md without
// needing the source repo on the user's container.
const PLAYBOOKS: &[(&str, &str)] = &[
("sourcing-ladder.md", include_str!("../playbooks/sourcing-ladder.md")),
("manufacturer-direct.md", include_str!("../playbooks/manufacturer-direct.md")),
("ultra-librarian.md", include_str!("../playbooks/ultra-librarian.md")),
("snapmagic.md", include_str!("../playbooks/snapmagic.md")),
("mouser-cse.md", include_str!("../playbooks/mouser-cse.md")),
("digikey-models.md", include_str!("../playbooks/digikey-models.md")),
("credentials-and-logins.md", include_str!("../playbooks/credentials-and-logins.md")),
("adom-desktop-api.md", include_str!("../playbooks/adom-desktop-api.md")),
("handoff-and-watching.md", include_str!("../playbooks/handoff-and-watching.md")),
("jst.md", include_str!("../playbooks/jst.md")),
("jst-email-retrieval.md", include_str!("../playbooks/jst-email-retrieval.md")),
];
const SESSION: &str = "adom-chip-fetcher";
const PROFILE: &str = "adom-chip-fetcher";
#[derive(Parser)]
#[command(name = "adom-chip-fetcher", version, about = "Download manufacturer STEP/footprint/datasheet bundles via pup")]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// PLAN a batch: show the user the full queue up front + seed planned-state
/// dashboard cards, BEFORE any scraping. This is the required first step —
/// browser-driving verbs (fetch/mfr-probe/scrape) REFUSE to run for an MPN
/// that isn't in the active plan. That's the guardrail that stops freelancing.
Plan {
/// The MPNs to source, in order.
mpns: Vec<String>,
/// Name the plan (defaults to "batch"). Becomes the active project.
#[arg(long, default_value = "batch")]
name: String,
},
/// Scan the native browser's SAVED LOGINS across every profile (Chrome +
/// Edge), map them to the sourcing ladder, and report which CAD/distributor
/// vendors you're signed in to + the best profile. adom-chip-fetcher uses this to
/// self-drive: it auto-switches to the vendor-authed profile while sourcing.
Logins,
/// Fetch a part's full CAD bundle (symbol+footprint+STEP) from Component
/// Search Engine via XHR + basic-auth — no browser download, no prompts, no
/// questions. ST/TI/NXP/etc. route their EDA models here. First-class +
/// permanent: works for any user with a CSE login in the credential vault.
Cse {
mpn: String,
/// Manufacturer name for CSE's `mna` param (default: inferred from MPN).
#[arg(long)]
mfr: Option<String>,
},
/// Set the human "what is this chip" description shown on the card + zoom
/// view (writes info.json.description). Every sourced chip should have one.
Describe {
mpn: String,
/// The description text (one or two sentences: what the part IS).
text: String,
},
/// Enrich a chip from the Adom parts tools (adom-parts-search → Mouser +
/// DigiKey + JLCPCB): writes the description, manufacturer, stock.json
/// (stock + pricing + lifecycle) and lists orderable package variants.
/// Keys are baked into those tools' own services — NO API key needed.
Enrich { mpn: String },
/// Run the MOST authoritative source: extract symbol + footprint from the
/// manufacturer DATASHEET via adom-ds2sf. GUARANTEES the datasheet is real
/// (multi-page + pinout) first — refuses a 1-page denial page — then runs
/// the extraction and renders the ds2sf variant thumbnails.
Ds2sf { mpn: String },
/// Fetch one MPN from the best available source
Fetch {
mpn: String,
/// Associate this chip with a project (e.g. "Tamara-LED-Nameplate")
#[arg(long)]
project: Option<String>,
},
/// Query the Adom Wiki ONLY (ladder step 0) and reuse a published bundle if
/// one exists. Same code path `fetch` runs first — exposed standalone so the
/// wiki reuse can be tested without driving the whole vendor ladder.
WikiFetch { mpn: String },
/// Open a source's login page so the user can authenticate once
Login { source: String },
/// Show what's already in the library
List,
/// Show what's downloaded vs missing for a single MPN
Status { mpn: String },
/// Import a downloaded file or zip into the library for an MPN
Import {
/// Path to a .zip, .step, .pdf, .kicad_mod, or .kicad_sym
path: PathBuf,
/// MPN to file under (default: infer from filename)
#[arg(long)]
mpn: Option<String>,
/// Skip the post-import ds2sf+concur validation step.
#[arg(long)]
no_validate: bool,
/// Where did you FIND the download link? e.g. "digikey" if you
/// found the link on DigiKey's models page, even though the file
/// was authored by Ultra Librarian. Chipsmith shows this in
/// tooltips: "found on DigiKey → made by UL".
#[arg(long, default_value = "unknown")]
discovery_source: String,
/// Who actually MADE the file? e.g. "manufacturer" if the STEP
/// is from Abracon's own CDN, "ultralibrarian" if UL generated
/// the KiCad bundle. This is the content attribution.
#[arg(long, default_value = "unknown")]
content_origin: String,
/// URL of the page/download this file came from.
#[arg(long, default_value = "")]
source_url: String,
},
/// Pull a file from the user's desktop and import it into the library.
/// e.g. `adom-chip-fetcher pull "C:\\Users\\john\\Downloads\\ul_TPS62840YBGR.zip" --mpn TPS62840YBGR`
Pull {
/// Windows/macOS path on the user's desktop
remote_path: String,
/// MPN to file under (default: infer from filename)
#[arg(long)]
mpn: Option<String>,
/// Skip the post-pull ds2sf+concur validation step.
#[arg(long)]
no_validate: bool,
/// Where did you FIND the download link?
#[arg(long, default_value = "unknown")]
discovery_source: String,
/// Who actually MADE the file?
#[arg(long, default_value = "unknown")]
content_origin: String,
/// URL of the page/download this file came from.
#[arg(long, default_value = "")]
source_url: String,
},
/// Deploy SKILL.md to ~/.claude/skills/adom-chip-fetcher/ so Claude knows how to use this tool.
/// Run after `curl ... | install` to finish setup.
Install,
/// Run the viewer HTTP server. Open in any browser at http://localhost:<port>.
Serve {
#[arg(long, default_value_t = 8786)]
port: u16,
},
/// Start the viewer + open it in a Hydrogen webview tab.
App {
#[arg(long, default_value_t = 8786)]
port: u16,
},
/// Heartbeat the dashboard. Run before every state change so users see
/// live progress. Stages are short labels: "navigating UL", "captcha
/// solved", "downloading STEP", "imported", "stuck — needs login", etc.
Heartbeat {
mpn: String,
stage: String,
},
/// Verify the rails before a fetch — dashboard reachable, heartbeat
/// fresh, pup session 'adom-chip-fetcher' open. Fails with HINT lines so the
/// next step is mechanical, not remembered.
Selfcheck,
/// Print a HINT line about what to do next, based on current state.
/// Useful when an AI agent has lost the thread mid-batch.
Hint,
/// Print the canonical sourcing-ladder priority order (Mfr → SnapMagic →
/// Mouser → DigiKey → Arrow → CSE/UL → LCSC). LCSC is LAST RESORT —
/// avoid if possible. AI: read this with `adom-chip-fetcher sources` BEFORE
/// falling back from one source to the next; do NOT skip ahead to LCSC.
Sources,
/// Hard gate: scan every chip in library/ and refuse to declare success
/// while ANY card is incomplete (stub-only or missing core CAD). Returns
/// non-zero exit when work remains. AI: ALWAYS run `adom-chip-fetcher gate`
/// before reporting "done" on a batch — it prints exactly which files
/// each card is missing, so claiming done is mechanical, not remembered.
Gate {
/// Strict mode: also require Altium IntLib + Fusion .lbr. Default
/// mode requires datasheet PDF + STEP + KiCad sym + KiCad mod.
#[arg(long)]
strict: bool,
/// Gate only this MPN (default: every chip in library/).
#[arg(long)]
mpn: Option<String>,
},
/// Backfill provenance for files that landed without going through import
/// (so their source isn't a bare "—"). Derives each present-but-
/// un-provenanced file's origin from info.json (step_source / the
/// sourcing-chain entry that succeeded) and records it; flags what it can't.
BackfillProvenance {
/// Backfill only this MPN (default: every chip in library/).
#[arg(long)]
mpn: Option<String>,
},
/// Detect manufacturer from MPN and open their canonical product /
/// search page in pup. AI: run this BEFORE any third-party source.
/// The mfr-first rule is enforced in code — `adom-chip-fetcher mfr-probe
/// <MPN>` either opens the right URL or tells you we can't infer the
/// manufacturer (in which case the AI must do its own probe). Auto-
/// records the manufacturer-attempt on info.json.fetched_via_chain.
MfrProbe {
mpn: String,
/// Print the URL only, don't open in pup. Useful for scripts.
#[arg(long)]
no_open: bool,
},
/// Print the next ladder step that hasn't been tried for this MPN,
/// reading from info.json.fetched_via_chain. The state machine: if
/// manufacturer hasn't been tried, the next source is "manufacturer".
/// If LCSC is suggested, the binary verifies the full ladder above it
/// has been recorded as failed first. AI: run this instead of
/// remembering the order.
NextSource {
mpn: String,
},
/// Run ds2sf + concur on a chip directory and walk hints[] to drive
/// autonomous self-repair. Loops on ds2sf needs_better_pdf candidates,
/// re-runs ds2sf with stronger model on concur recommendation,
/// applies update_upstream_metadata patches, marks symbol source as
/// ds2sf-preferred when UL has stub pin names, and persists final
/// status to <MPN>-validation-status.json. Surfaces to human cleanly
/// when out of automated options.
Validate {
/// Chip MPN — directory must already exist at library/<MPN>/.
mpn: String,
},
/// Drive the adom-chip-fetcher pup scrape session. Every action auto-
/// heartbeats the dashboard, always uses `profile: "adom-chip-fetcher"`
/// (NEVER a different profile — that loses vendor login cookies),
/// and logs every step to the fetched_via_chain. The AI calls these
/// instead of raw `adom-desktop browser_*` calls so the dashboard
/// shows live progress and credentials are never lost.
Scrape {
#[command(subcommand)]
op: ScrapeOp,
},
/// Manage stored vendor credentials. Saved to
/// ~/.config/adom-chip-fetcher/credentials.json on the user's container.
/// Survives across sessions — the user enters creds ONCE, adom-chip-fetcher
/// can re-login automatically when cookies expire.
Creds {
#[command(subcommand)]
op: CredsOp,
},
/// Print the full completeness ladder for one chip or the whole
/// library. Shows every level (0-9) with ✓/✗ and what's missing at
/// each level. The AI reads this output to decide what to do next
/// instead of guessing.
Done {
/// Check only this MPN (default: every chip in library/).
#[arg(long)]
mpn: Option<String>,
},
/// Sweep every chip's <MPN>.pdf and report which ones are HTML
/// masquerading as a PDF (Cloudflare denial pages, "click to accept"
/// interstitials, 404 HTML, etc.). Reads first 5 bytes; rejects
/// anything not starting with `%PDF-`. With --repair, deletes the
/// bogus file + records the failure in info.json.fetched_via_chain
/// so the next refetch knows the previous source returned garbage.
/// Always run this after a batch fetch to catch the silent footgun
/// that lands HTML to .pdf when a vendor wall-blocks curl.
PdfCheck {
/// Check only this MPN (default: every chip in library/).
#[arg(long)]
mpn: Option<String>,
/// Delete bogus PDFs + record `html_masquerade` outcome in
/// info.json.fetched_via_chain so next-source advances correctly.
#[arg(long)]
repair: bool,
},
/// Drive pup to a vendor page that wall-blocks curl, find the real
/// PDF anchor on the rendered page, fetch it via `fetch().arrayBuffer()`
/// inside the browser context (cookies + Cloudflare-passed Chrome
/// fingerprint preserved), base64-transfer the bytes back, magic-byte
/// validate, and save to `library/<MPN>/<MPN>.pdf`. The escape hatch
/// for vendors that detect curl and serve HTML to it (Hanrun's Aliyun
/// CDN, ST CDN, JST product pages, etc.) — proven pattern from the
/// 2026-05-07 HR911105A recovery.
PupPdfFetch {
/// MPN to file the PDF under.
mpn: String,
/// Page URL to navigate to first. The PDF anchor is searched on
/// this rendered page. If both --page-url and --pdf-url are given,
/// --page-url is the referrer (cookies set), --pdf-url is fetched
/// directly.
#[arg(long)]
page_url: Option<String>,
/// Direct PDF URL to fetch via `fetch()`. Skips the page-anchor
/// search step. Recommended when the URL is already known (from
/// info.json.datasheet_url, a prior chain entry, etc.).
#[arg(long)]
pdf_url: Option<String>,
/// Source slug for the chain ledger (manufacturer / snapmagic /
/// mouser / digikey / arrow / cse_ul / lcsc). Default:
/// `manufacturer`.
#[arg(long, default_value = "manufacturer")]
source: String,
/// Pup session id (default: `cf-pdf-grab` — kept separate from
/// the dashboard `adom-chip-fetcher` and the scrape `adom-chip-fetcher-scrape`
/// sessions so this can run alongside an existing fetch).
#[arg(long, default_value = "cf-pdf-grab")]
session: String,
},
/// Re-fetch a specific library file from a specific source. Drives the
/// vendor's product/CAD page in pup, watches ~/Downloads for the
/// resulting bundle, and imports it. Used by `adom-chip-fetcher validate`
/// when concur returns `refetch_library_source` — the loop calls this
/// as a subprocess so a divergent kicad_sym can be replaced with one
/// from a different upstream without a human in the loop.
///
/// Sources accepted (canonical slugs): manufacturer, snapmagic, mouser,
/// digikey, arrow, cse_ul, lcsc.
/// Formats accepted: kicad_sym, kicad_mod, eagle, step, all.
Refetch {
mpn: String,
/// What kind of file we want refreshed.
#[arg(long)]
format: String,
/// Which ladder step to refetch from.
#[arg(long)]
source: String,
/// Don't open pup; just print the canonical URL we'd drive to.
/// Used by validate's autonomous loop to record provenance without
/// stealing the user's screen.
#[arg(long)]
url_only: bool,
/// How long to wait for a download to land in ~/Downloads (seconds).
#[arg(long, default_value_t = 180)]
timeout: u64,
},
/// Manage the dashboard surface — auto-(re)open it (default), force a
/// fresh window, or set the surface preference.
Dashboard {
#[command(subcommand)]
op: DashboardOp,
},
/// Generate thumbnail images (symbol SVG, footprint SVG, 3D chip PNG)
/// for one MPN or the whole library. Idempotent: skips artifacts whose
/// source hasn't changed since the last generation.
Thumbnails {
/// Generate for this single MPN (default: every chip in library/)
#[arg(long)]
mpn: Option<String>,
/// Re-render even when the existing thumbnail is newer than its source
#[arg(long)]
force: bool,
},
/// Manage projects — scope adom-chip-fetcher work to a named project so the
/// dashboard shows only the chips you care about right now.
Project {
#[command(subcommand)]
op: ProjectOp,
},
}
#[derive(Subcommand)]
enum ProjectOp {
/// Create a new project
Create {
name: String,
/// Short description of the project
#[arg(long, default_value = "")]
description: String,
},
/// List all projects
List,
/// Show chips in a project
Show { name: String },
/// Add an MPN to a project
Add { name: String, mpn: String },
/// Remove an MPN from a project
Remove { name: String, mpn: String },
/// Set the active project (filters the dashboard)
Activate { name: String },
/// Clear the active project (show full library)
Deactivate,
/// Delete a project (does not delete library chips)
Delete { name: String },
}
#[derive(Subcommand)]
enum DashboardOp {
/// Make sure the dashboard is up and visible. Idempotent. Pup by default.
Ensure {
/// Re-open even if a adom-chip-fetcher pup window is already detected.
#[arg(long)]
force: bool,
},
/// Set the surface preference (pup | webview). Saved at
/// ~/.config/adom-chip-fetcher/dashboard.json. Pup is the default since
/// adom-chip-fetcher needs pup anyway to drive vendor sites.
SetSurface {
/// Either "pup" or "webview"
surface: String,
},
/// Print the current surface preference.
Surface,
}
/// Every pup action the AI drives during a fetch. ALWAYS uses
/// `profile: "adom-chip-fetcher"` — hardcoded, non-overridable. This
/// preserves vendor login cookies across sessions and ensures the
/// dashboard heartbeat fires on every action.
#[derive(Subcommand)]
enum ScrapeOp {
/// Navigate the scrape pup window to a URL. Auto-heartbeats.
Navigate {
mpn: String,
url: String,
/// Stage label for the heartbeat (default: "navigating <url>")
#[arg(long)]
stage: Option<String>,
},
/// Eval JS in the scrape pup window. Auto-heartbeats. Prints the
/// result to stdout so the AI reads it back.
Eval {
mpn: String,
expr: String,
},
/// Screenshot the scrape pup window. Saves to /tmp/ and prints path.
Screenshot {
mpn: String,
},
/// Open the scrape window (or re-open if dead). Always profile=adom-chip-fetcher.
Open {
mpn: String,
url: String,
},
}
#[derive(Subcommand)]
enum CredsOp {
/// List stored credentials (shows host + username, masks password).
List,
/// Save a credential. Persists to ~/.config/adom-chip-fetcher/credentials.json.
Save {
/// Vendor host slug (snapeda, ultralib, componentsearch, ti, st, etc.)
host: String,
username: String,
password: String,
},
/// Delete a stored credential by host.
Delete { host: String },
}
pub fn run() -> Result<()> {
let cli = Cli::parse();
match cli.cmd {
Cmd::Logins => cmd_logins(),
Cmd::Cse { mpn, mfr } => cmd_cse(&mpn, mfr.as_deref()),
Cmd::Describe { mpn, text } => cmd_describe(&mpn, &text),
Cmd::Enrich { mpn } => cmd_enrich(&mpn),
Cmd::Ds2sf { mpn } => cmd_ds2sf(&mpn),
Cmd::Plan { mpns, name } => cmd_plan(&mpns, &name),
Cmd::Fetch { mpn, project } => {
if let Some(ref proj) = project {
let _ = crate::projects::add_mpn(proj, &mpn);
let _ = crate::projects::set_active(Some(proj));
println!("project: {proj} (added {mpn})");
}
require_planned(&mpn)?;
fetch(&mpn)
}
Cmd::WikiFetch { mpn } => wiki_fetch_cmd(&mpn),
Cmd::Login { source } => login(&source),
Cmd::List => list(),
Cmd::Status { mpn } => status(&mpn),
Cmd::Import { path, mpn, no_validate, discovery_source, content_origin, source_url } =>
import_cmd(&path, mpn.as_deref(), no_validate, &discovery_source, &content_origin, &source_url),
Cmd::Pull { remote_path, mpn, no_validate, discovery_source, content_origin, source_url } =>
pull_cmd(&remote_path, mpn.as_deref(), no_validate, &discovery_source, &content_origin, &source_url),
Cmd::Install => install_cmd(),
Cmd::Serve { port } => serve::run(port),
Cmd::App { port } => app_cmd(port),
Cmd::Heartbeat { mpn, stage } => hints::heartbeat(&mpn, &stage),
Cmd::Selfcheck => hints::selfcheck(),
Cmd::Hint => hint_cmd(),
Cmd::Sources => sources_cmd(),
Cmd::Gate { strict, mpn } => gate_cmd(strict, mpn.as_deref()),
Cmd::BackfillProvenance { mpn } => backfill_provenance_cmd(mpn.as_deref()),
Cmd::MfrProbe { mpn, no_open } => { require_planned(&mpn)?; mfr_probe_cmd(&mpn, no_open) }
Cmd::NextSource { mpn } => next_source_cmd(&mpn),
Cmd::Validate { mpn } => validate_cmd(&mpn),
Cmd::Refetch { mpn, format, source, url_only, timeout } =>
refetch_cmd(&mpn, &format, &source, url_only, timeout),
Cmd::Done { mpn } => done_cmd(mpn.as_deref()),
Cmd::Scrape { op } => scrape_cmd(op),
Cmd::Creds { op } => creds_cmd(op),
Cmd::PdfCheck { mpn, repair } => pdf_check_cmd(mpn.as_deref(), repair),
Cmd::PupPdfFetch { mpn, page_url, pdf_url, source, session } =>
pup_pdf_fetch_cmd(&mpn, page_url.as_deref(), pdf_url.as_deref(), &source, &session),
Cmd::Dashboard { op } => dashboard_cmd(op),
Cmd::Thumbnails { mpn, force } => thumbnails_cmd(mpn.as_deref(), force),
Cmd::Project { op } => project_cmd(op),
}
}
/// First-class CSE source: fetch the full CAD bundle via XHR + basic-auth (no
/// download, no prompt, no questions) and import it. The permanent version of
/// what unblocked the VL53Lx family.
fn cmd_cse(mpn: &str, mfr: Option<&str>) -> Result<()> {
require_planned(mpn)?;
let cred = crate::creds::load()?.into_iter()
.find(|c| c.host.contains("componentsearchengine"))
.ok_or_else(|| anyhow::anyhow!(
"no Component Search Engine credential in the vault.\n \
HINT: sign in to componentsearchengine.com once in your browser, or \
`adom-chip-fetcher creds save componentsearchengine <user> <pass>`"
))?;
let mfr = mfr.map(String::from)
.or_else(|| sourcing::guess_mfr(mpn).map(|g| g.manufacturer.to_string()))
.ok_or_else(|| anyhow::anyhow!("could not infer manufacturer for {mpn}; pass --mfr <name>"))?;
// Keep the dashboard "working on" bar LIVE for the whole browser fetch —
// cse_fetch_zip blocks ~40s on the XHR chain, so a single heartbeat would
// go stale mid-fetch. The ticker re-pulses every 6s until it's stopped.
let ticker = hints::HeartbeatTicker::start(mpn, &format!("CSE: fetching CAD via your browser ({mfr})"));
let bytes = pup::cse_fetch_zip(mpn, &mfr, &cred.username, &cred.password)?;
ticker.stop();
let zip = std::env::temp_dir().join(format!("cse_{mpn}.zip"));
std::fs::write(&zip, &bytes)?;
// Keep the ORIGINAL downloaded archive in the library so the user can
// inspect the exact bytes we pulled off the internet (folder structure, all
// EDA formats, the 3D, etc.) — not just the files we extracted from it.
let orig = library::root().join(mpn).join(format!("{mpn}.cse-bundle.zip"));
let _ = std::fs::create_dir_all(orig.parent().unwrap());
let _ = std::fs::write(&orig, &bytes);
println!("OK: CSE zip {} KB → saved original + importing symbol+footprint+STEP", bytes.len() / 1024);
let _ = hints::heartbeat(mpn, "importing CSE bundle");
import_cmd(&zip, Some(mpn), false, "cse", "manufacturer", "")?;
let _ = hints::heartbeat(mpn, "imported");
// First-class metadata: pull description + stock + pricing from the Adom
// parts tools (keys baked in — no API key). Best-effort; never fails the run.
auto_enrich(mpn);
// The DATASHEET is the most authoritative source — ALWAYS run ds2sf so every
// fetched chip gets the independent datasheet extraction. Best-effort: a
// ds2sf failure doesn't fail the CAD fetch, but the gate WILL flag it (so it
// can never be silently skipped). Set CHIP_FETCHER_SKIP_DS2SF=1 to opt out.
if std::env::var("CHIP_FETCHER_SKIP_DS2SF").is_err() {
if let Err(e) = cmd_ds2sf(mpn) {
println!("WARN: ds2sf did not complete for {mpn}: {e}");
}
}
warn_if_no_description(mpn);
Ok(())
}
/// Best-effort enrichment from the Adom parts tools after a fetch. Quietly
/// no-ops if adom-parts-search isn't reachable.
fn auto_enrich(mpn: &str) {
let _ = hints::heartbeat(mpn, "enriching from adom-parts-search");
match crate::enrich::enrich(mpn) {
Ok(r) => {
if let Some(d) = &r.description {
println!(" enrich: description set — {}", d.chars().take(70).collect::<String>());
}
if r.wrote_stock { println!(" enrich: stock.json written (stock + pricing + lifecycle)"); }
}
Err(e) => println!(" enrich: skipped ({e})"),
}
}
/// Read a chip's info.json.description (empty if none).
fn chip_description(mpn: &str) -> String {
let p = library::root().join(mpn).join("info.json");
std::fs::read_to_string(&p).ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| v.get("description").and_then(|d| d.as_str()).map(String::from))
.unwrap_or_default()
}
/// After sourcing, nudge the AI to set a human description if the chip has none
/// — the card + zoom view show it, and a blank "what is this chip" reads as
/// broken. Self-enforcing, same spirit as the gate/completeness warnings.
fn warn_if_no_description(mpn: &str) {
if chip_description(mpn).trim().is_empty() {
println!("HINT: {mpn} has no description — the card/zoom view will show a blank \
'what is this chip'. Set one: `adom-chip-fetcher describe {mpn} \"<one-line what it is>\"`");
}
}
/// Run adom-ds2sf on a chip — the datasheet IS the most authoritative source.
/// Hard-guarantees a real datasheet first, then extracts + renders the variant.
fn cmd_ds2sf(mpn: &str) -> Result<()> {
let dir = library::root().join(mpn);
if !dir.exists() { anyhow::bail!("library/{mpn}/ does not exist"); }
let pdf = dir.join(format!("{mpn}.pdf"));
// GUARANTEE the datasheet is usable BEFORE spending a Claude extraction on it.
match crate::import::datasheet_ready(&pdf) {
Ok(q) => println!("OK: datasheet ready — {} pages, pinout detected", q.pages),
Err(reason) => {
hints::print_hint(&format!("{mpn}: datasheet NOT usable for ds2sf — {reason}. \
Re-fetch a real datasheet (`adom-chip-fetcher pup-pdf-fetch {mpn} --pdf-url <mfr lit URL>`), then re-run."));
anyhow::bail!("ds2sf needs a real datasheet for {mpn}: {reason}");
}
}
let _ = hints::heartbeat(mpn, "ds2sf: extracting symbol+footprint from datasheet");
let status = std::process::Command::new("adom-ds2sf")
.args(["extract"]).arg(&dir).arg("--force").arg("--json-only")
.status()
.map_err(|e| anyhow::anyhow!("could not exec adom-ds2sf (install it): {e}"))?;
if !status.success() {
let result = std::fs::read_to_string(dir.join(format!("{mpn}-extraction.result.json"))).ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());
let why = result.as_ref().and_then(|r| r.get("summary").and_then(|s| s.as_str())).unwrap_or("see extraction.result.json");
anyhow::bail!("ds2sf extraction failed for {mpn}: {why}");
}
// Inject the canonical RefDes/Value textPlacement into the footprint
// extraction so exporters (adom-lbr → Fusion/.lbr, Altium, OrCAD) validate
// against intent instead of re-deriving from pads. adom-footprint is the
// single source of truth for placement; best-effort (skips if no pads).
let fp_json = dir.join(format!("{mpn}-footprint.extracted.json"));
if fp_json.is_file() {
let _ = std::process::Command::new("adom-footprint")
.args(["placement", "--write", "--file"])
.arg(&fp_json)
.output();
}
// Render the ds2sf variant thumbnails so the carat + validation surface them.
auto_render_thumbnails(mpn);
let _ = hints::heartbeat(mpn, "ds2sf: extracted");
println!("OK: {mpn} — ds2sf symbol + footprint extracted from the datasheet");
Ok(())
}
/// Enrich a chip from the Adom parts tools and report what was written.
fn cmd_enrich(mpn: &str) -> Result<()> {
let r = crate::enrich::enrich(mpn)?;
match &r.description {
Some(d) => println!("OK: {mpn} description — {d}"),
None => println!("WARN: {mpn} — no description found via adom-parts-search"),
}
if r.wrote_stock { println!("OK: {mpn} stock.json written (stock + pricing + lifecycle)"); }
if !r.variants.is_empty() {
println!("OK: {mpn} orderable variants: {}", r.variants.join(", "));
}
Ok(())
}
/// Write the human description into library/<MPN>/info.json (creating the file
/// if needed). Shown on the dashboard card and the zoom focus view.
fn cmd_describe(mpn: &str, text: &str) -> Result<()> {
let dir = library::root().join(mpn);
if !dir.exists() {
anyhow::bail!("library/{mpn}/ does not exist — source the chip first");
}
let p = dir.join("info.json");
let mut info: serde_json::Value = std::fs::read_to_string(&p).ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_else(|| serde_json::json!({}));
info["description"] = serde_json::Value::String(text.to_string());
std::fs::write(&p, serde_json::to_string_pretty(&info)?)?;
println!("OK: {mpn} description set ({} chars)", text.len());
Ok(())
}
/// First-class credential awareness: scan saved logins across every browser
/// profile, map to the ladder, report coverage + the best profile. The sourcing
/// flow auto-switches profiles per vendor using the same data.
fn cmd_logins() -> Result<()> {
use std::collections::{BTreeMap, BTreeSet};
let creds = pup::credentials();
let mut by: BTreeMap<String, BTreeMap<&str, BTreeSet<String>>> = BTreeMap::new();
for c in creds {
for (frag, name) in pup::VENDOR_HOSTS {
if c.host.contains(frag) {
by.entry(format!("{} · {}", c.browser, c.profile))
.or_default().entry(*name).or_default()
.insert(if c.username.is_empty() { "(saved)".into() } else { c.username.clone() });
}
}
}
if by.is_empty() {
println!("No saved CAD/distributor logins found in the native browser.");
println!("HINT: install/enable the Adom browser extension, or sign in to a vendor once.");
return Ok(());
}
let mut ranked: Vec<_> = by.into_iter().collect();
ranked.sort_by_key(|(_, v)| std::cmp::Reverse(v.len()));
println!("adom-chip-fetcher — saved CAD/distributor logins (read-only; never passwords):");
for (prof, vmap) in &ranked {
println!("\n [{prof}] — {} vendor(s):", vmap.len());
for (name, users) in vmap {
println!(" {name:16} {}", users.iter().cloned().collect::<Vec<_>>().join(", "));
}
}
let (best, bv) = &ranked[0];
println!("\nBEST profile for sourcing: {best} ({} vendors). adom-chip-fetcher auto-switches \
to the right signed-in profile per vendor as it sources — no questions.", bv.len());
// Per-BROWSER score: which native browser (Chrome vs Edge) covers the most
// of the electronics industry across all its profiles.
let total_vendors = pup::VENDOR_HOSTS.iter().map(|(_, n)| *n).collect::<BTreeSet<_>>().len();
let mut by_browser: BTreeMap<String, BTreeSet<&str>> = BTreeMap::new();
for c in creds {
for (frag, name) in pup::VENDOR_HOSTS {
if c.host.contains(frag) { by_browser.entry(c.browser.clone()).or_default().insert(*name); }
}
}
let mut bscore: Vec<_> = by_browser.into_iter().collect();
bscore.sort_by_key(|(_, v)| std::cmp::Reverse(v.len()));
println!("\n=== Native-browser industry-coverage score ===");
for (br, v) in &bscore {
println!(" {:6} {}/{} vendors ({})", br, v.len(), total_vendors, v.iter().cloned().collect::<Vec<_>>().join(", "));
}
if let Some((winner, v)) = bscore.first() {
println!("\nWINNER: drive in **{winner}** — broadest electronics-industry login coverage ({}/{} vendors).", v.len(), total_vendors);
}
Ok(())
}
/// Seed the user-visible plan + planned-state cards BEFORE any scraping, so the
/// user sees the whole queue up front. Required first step of every batch.
fn cmd_plan(mpns: &[String], name: &str) -> Result<()> {
if mpns.is_empty() {
anyhow::bail!("no MPNs given.\n Usage: adom-chip-fetcher plan <MPN1> <MPN2> ... [--name <plan>]");
}
let _ = crate::projects::create_project(name, "adom-chip-fetcher plan");
crate::projects::set_active(Some(name))?;
if let Err(e) = hints::dashboard_ensure(false) {
println!("WARN: could not auto-open the dashboard ({e}); start it with `adom-chip-fetcher serve`");
}
println!("PLAN '{name}' — {} chip(s) queued (the user sees these as planned cards now):", mpns.len());
for (i, mpn) in mpns.iter().enumerate() {
let _ = crate::projects::add_mpn(name, mpn);
let _ = hints::heartbeat(mpn, "planned"); // side-effect: seeds the stub card
println!(" {}. {mpn}", i + 1);
}
println!("\nNow source them — manufacturer-first, and every verb heartbeats live to the dashboard:");
println!(" adom-chip-fetcher mfr-probe <MPN> # required first source (the maker)");
println!(" adom-chip-fetcher next-source <MPN> # the next ladder step to try");
println!(" adom-chip-fetcher gate # refuses 'done' while any card is incomplete");
Ok(())
}
/// PREFLIGHT GUARD — every browser-driving verb (fetch / mfr-probe / scrape)
/// calls this FIRST. It refuses (non-zero exit + HINT) to scrape any MPN the
/// user hasn't seen in an active plan, and refuses if the dashboard isn't live
/// to show working status. This is the programmatic backstop that makes
/// "willy-nilly AI scraping with no insight" structurally impossible — for this
/// agent and for every third party that drives the tool.
fn require_planned(mpn: &str) -> Result<()> {
let active = crate::projects::get_active().ok_or_else(|| anyhow::anyhow!(
"REFUSED: no active plan — the user must see the queue before any scraping.\n \
HINT: adom-chip-fetcher plan {mpn} [more MPNs...] then source."
))?;
if !crate::projects::mpns_for_project(&active).iter().any(|m| m == mpn) {
anyhow::bail!(
"REFUSED: {mpn} is not in the active plan '{active}'. Don't scrape chips the user never saw planned.\n \
HINT: adom-chip-fetcher plan {mpn} --name {active}"
);
}
if !dashboard_reachable() {
anyhow::bail!(
"REFUSED: the dashboard isn't reachable on :8786, so the user would get no working-status insight.\n \
HINT: adom-chip-fetcher serve (then retry) — the dashboard is the user's home base."
);
}
Ok(())
}
fn dashboard_reachable() -> bool {
use std::net::TcpStream;
use std::time::Duration;
"127.0.0.1:8786"
.parse()
.ok()
.and_then(|addr| TcpStream::connect_timeout(&addr, Duration::from_millis(800)).ok())
.is_some()
}
fn project_cmd(op: ProjectOp) -> Result<()> {
use crate::projects;
match op {
ProjectOp::Create { name, description } => {
projects::create_project(&name, &description)?;
println!("OK: created project '{name}'");
}
ProjectOp::List => {
let active = projects::get_active();
let projs = projects::list_projects();
if projs.is_empty() {
println!("no projects. create one with: adom-chip-fetcher project create <name>");
}
for p in &projs {
let marker = if active.as_deref() == Some(&p.name) { " (active)" } else { "" };
println!(" {} — {} chips{}", p.name, p.mpns.len(), marker);
}
}
ProjectOp::Show { name } => {
match projects::get_project(&name) {
Some(p) => {
println!("project: {}", p.name);
if !p.description.is_empty() { println!(" {}", p.description); }
println!(" chips ({}):", p.mpns.len());
for m in &p.mpns { println!(" {m}"); }
}
None => println!("project '{name}' not found"),
}
}
ProjectOp::Add { name, mpn } => {
projects::add_mpn(&name, &mpn)?;
println!("OK: added {mpn} to project '{name}'");
}
ProjectOp::Remove { name, mpn } => {
projects::remove_mpn(&name, &mpn)?;
println!("OK: removed {mpn} from project '{name}'");
}
ProjectOp::Activate { name } => {
projects::set_active(Some(&name))?;
println!("OK: active project set to '{name}'");
hints::print_hint("dashboard will now filter to this project's chips");
}
ProjectOp::Deactivate => {
projects::set_active(None)?;
println!("OK: active project cleared — dashboard shows full library");
}
ProjectOp::Delete { name } => {
projects::delete_project(&name)?;
println!("OK: deleted project '{name}' (library chips untouched)");
}
}
Ok(())
}
fn thumbnails_cmd(mpn: Option<&str>, force: bool) -> Result<()> {
if let Some(m) = mpn {
let _ = hints::heartbeat(m, "rendering thumbnails");
let r = thumbnails::generate_for(m, force)?;
print_thumb_report(&r);
let _ = hints::heartbeat(m, "thumbnails ready");
} else {
println!("rendering thumbnails for every chip in library/...");
let reports = thumbnails::generate_all(force);
let mut total_gen = 0;
let mut total_skip = 0;
let mut total_err = 0;
for r in &reports {
print_thumb_report(r);
total_gen += r.generated.len();
total_skip += r.skipped.len();
total_err += r.errors.len();
}
println!();
println!("OK: {} chips processed — {total_gen} generated, {total_skip} skipped (up-to-date), {total_err} errors", reports.len());
hints::print_hint("open the dashboard to see them: `adom-chip-fetcher dashboard ensure`");
}
Ok(())
}
fn print_thumb_report(r: &thumbnails::GenReport) {
let parts: Vec<String> = r.generated.iter().map(|s| format!("+{s}")).chain(
r.skipped.iter().map(|s| format!("={s}"))
).chain(
r.errors.iter().map(|(k,_)| format!("!{k}"))
).collect();
println!(" {:<24} {}", r.mpn, parts.join(" "));
for (k, e) in &r.errors {
println!(" ERR {k}: {e}");
}
}
fn dashboard_cmd(op: Cmd_) -> Result<()> { dashboard_dispatch(op) }
// Shim: clap's `#[command(subcommand)]` argument needs the parent type to
// resolve, so we route through this small helper.
type Cmd_ = DashboardOp;
fn dashboard_dispatch(op: DashboardOp) -> Result<()> {
match op {
DashboardOp::Ensure { force } => hints::dashboard_ensure(force),
DashboardOp::SetSurface { surface } => {
let parsed = match surface.trim().to_lowercase().as_str() {
"pup" => hints::DashboardSurface::Pup,
"webview" | "hydrogen" => hints::DashboardSurface::Webview,
_ => anyhow::bail!("surface must be 'pup' or 'webview'"),
};
hints::set_dashboard_surface(parsed)?;
hints::print_hint("verify with `adom-chip-fetcher dashboard ensure`");
Ok(())
}
DashboardOp::Surface => {
let s = hints::get_dashboard_surface();
println!("dashboard surface: {:?}", s);
hints::print_hint("change with `adom-chip-fetcher dashboard set-surface pup|webview`");
Ok(())
}
}
}
/// Full completeness ladder for one chip or the whole library.
/// 9 levels, each checked from the filesystem. The AI reads this to
/// decide what to do next — no guessing, no "I think it's done."
fn done_cmd(mpn_filter: Option<&str>) -> Result<()> {
let lib = inventory::scan()?;
let chips: Vec<_> = lib.chips.into_iter()
.filter(|c| mpn_filter.is_none_or(|m| c.mpn == m))
.collect();
if chips.is_empty() {
if let Some(m) = mpn_filter { anyhow::bail!("no chip {m} in library"); }
println!("library is empty."); return Ok(());
}
for c in &chips {
let dir = crate::library::root().join(&c.mpn);
let has_stock = dir.join("stock.json").exists();
let has_prov = dir.join(format!("{}-file-provenance.json", c.mpn)).exists();
let has_val_status = c.validation_status.is_some();
let val_golden = matches!(c.validation_status.as_deref(), Some("golden") | Some("golden_with_variants"));
let has_thumbs = c.has_thumb_symbol && c.has_thumb_footprint && c.has_thumb_chip3d;
let levels: [(u8, &str, bool, &str); 9] = [
(1, "Datasheet PDF", c.has_pdf, if c.has_pdf { "" } else { "fetch <MPN>.pdf from mfr → SnapMagic → Mouser → DigiKey" }),
(2, "3D STEP model", c.has_step, if c.has_step { "" } else { "fetch STEP from mfr or DigiKey models page" }),
(3, "KiCad sym+mod", c.has_sym && c.has_mod, if c.has_sym && c.has_mod { "" } else {
if !c.has_sym && !c.has_mod { "fetch .kicad_sym + .kicad_mod from UL/SnapMagic/stdlib" }
else if !c.has_sym { "fetch .kicad_sym" } else { "fetch .kicad_mod" }
}),
(4, "Altium library", c.has_altium || c.altium_not_available, if c.has_altium { "" } else if c.altium_not_available { "(not available from any source)" } else { "fetch .IntLib or .SchLib+.PcbLib from SnapMagic/CSE" }),
(5, "Fusion .lbr", c.has_fusion_lbr || c.fusion_not_available, if c.has_fusion_lbr { "" } else if c.fusion_not_available { "(not available from any source)" } else { "fetch .lbr from SnapMagic/CSE" }),
(6, "Thumbnails", has_thumbs, if has_thumbs { "" } else { "`adom-chip-fetcher thumbnails --mpn <MPN> --force`" }),
(7, "Validated", val_golden, if val_golden { "" } else if has_val_status { "re-run: `adom-chip-fetcher validate <MPN>`" } else { "run: `adom-chip-fetcher validate <MPN>` (needs PDF + sym + mod)" }),
(8, "Mouser stock", has_stock, if has_stock { "" } else { "fetch stock.json from Mouser API or product page" }),
(9, "File provenance", has_prov, if has_prov { "" } else { "re-import with --discovery-source + --content-origin flags" }),
];
let done_count = levels.iter().filter(|(_, _, ok, _)| *ok).count();
println!("{} — {}/9 levels complete", c.mpn, done_count);
for (num, name, ok, fix) in &levels {
let mark = if *ok { "✓" } else { "✗" };
let fix_hint = if fix.is_empty() { String::new() } else { format!(" → {fix}") };
println!(" {mark} {num}. {name}{fix_hint}");
}
if done_count < 9 {
let next = levels.iter().find(|(_, _, ok, _)| !*ok);
if let Some((_, name, _, fix)) = next {
println!();
hints::print_hint(&format!("next step for {}: {} — {}", c.mpn, name, fix));
}
}
println!();
}
let total = chips.len();
let fully = chips.iter().filter(|c| {
let dir = crate::library::root().join(&c.mpn);
c.has_pdf && c.has_step && c.has_sym && c.has_mod && (c.has_altium || c.altium_not_available) && (c.has_fusion_lbr || c.fusion_not_available)
&& c.has_thumb_symbol && c.has_thumb_footprint && c.has_thumb_chip3d
&& matches!(c.validation_status.as_deref(), Some("golden") | Some("golden_with_variants"))
&& dir.join("stock.json").exists()
&& dir.join(format!("{}-file-provenance.json", c.mpn)).exists()
}).count();
if total > 1 {
println!("SUMMARY: {fully}/{total} chips at level 9 (fully complete)");
}
Ok(())
}
// ---- Hardcoded profile for all pup scraping ----
// The user's vendor login cookies (SnapEDA, UL, CSE, TI, ST, Mouser,
// DigiKey) live in the "adom-chip-fetcher" Chrome profile. Creating ANY
// other profile loses every credential. This constant is the single
// source of truth — every scrape/refetch/pup-pdf-fetch path uses it.
const SCRAPE_PROFILE: &str = "adom-chip-fetcher";
const SCRAPE_SESSION: &str = "adom-chip-fetcher-scrape";
fn scrape_cmd(op: ScrapeOp) -> Result<()> {
match op {
ScrapeOp::Open { mpn, url } => {
require_planned(&mpn)?;
let _ = hints::heartbeat(&mpn, &format!("scrape: opening {}", &url[..url.len().min(60)]));
// ONE window: the scrape tab lives beside the dashboard in the
// adom-chip-fetcher window (native) — no separate scrape window.
pup::scrape_navigate(&url)?;
println!("OK: scrape tab open at {url} ({})", pup::surface_label());
hints::print_hint(&format!("page is loading. Next: `adom-chip-fetcher scrape eval {mpn} '<JS>'` to inspect, or `adom-chip-fetcher scrape screenshot {mpn}`."));
Ok(())
}
ScrapeOp::Navigate { mpn, url, stage } => {
require_planned(&mpn)?;
let label = stage.unwrap_or_else(|| format!("navigating {}", &url[..url.len().min(50)]));
let _ = hints::heartbeat(&mpn, &label);
pup::scrape_navigate(&url)?;
println!("OK: navigated to {url}");
hints::print_hint(&format!("page loaded. Next: eval JS to find download links, or screenshot. Check `adom-chip-fetcher next-source {mpn}` if this source lacks what you need."));
Ok(())
}
ScrapeOp::Eval { mpn, expr } => {
let short = if expr.len() > 40 { format!("{}…", &expr[..40]) } else { expr.clone() };
let _ = hints::heartbeat(&mpn, &format!("scrape eval: {short}"));
let result = pup::scrape_eval(&expr)?;
println!("{}", serde_json::to_string_pretty(&result)?);
hints::print_hint(&format!("read the result. For a PDF URL use `adom-chip-fetcher pup-pdf-fetch {mpn} --pdf-url <url>`; for CAD use `adom-chip-fetcher refetch {mpn} --format <fmt> --source <src>`."));
Ok(())
}
ScrapeOp::Screenshot { mpn } => {
let _ = hints::heartbeat(&mpn, "scrape: taking screenshot");
let v = pup::scrape_screenshot()?;
let path = v.get("savedTo").and_then(|p| p.as_str())
.or_else(|| v.get("output").and_then(|o| o.as_str()))
.unwrap_or("(captured)");
println!("OK: screenshot {path}");
hints::print_hint("look at the screenshot. If a modal blocks, dismiss it with `scrape eval` first.");
Ok(())
}
}
}
fn creds_cmd(op: CredsOp) -> Result<()> {
match op {
CredsOp::List => {
let creds = crate::creds::load()?;
if creds.is_empty() {
println!("no stored credentials");
println!();
hints::print_hint("save creds with: adom-chip-fetcher creds save <host> <username> <password>");
hints::print_hint("hosts: snapeda, ultralib, componentsearch, ti, st, microchip, google");
} else {
println!("stored credentials ({}):", creds.len());
for c in &creds {
let masked = if c.password.len() > 2 {
format!("{}…{}", &c.password[..1], &c.password[c.password.len()-1..])
} else { "***".into() };
println!(" {:<20} user={:<25} pass={}", c.host, c.username, masked);
}
}
Ok(())
}
CredsOp::Save { host, username, password } => {
crate::creds::set(&host, &username, &password)?;
println!("OK: saved credential for {host} (user={username})");
println!("stored at ~/.config/adom-chip-fetcher/credentials.json");
Ok(())
}
CredsOp::Delete { host } => {
crate::creds::delete(&host)?;
println!("OK: deleted credential for {host}");
Ok(())
}
}
}
/// PDF magic-byte sweep. Walks every `<MPN>.pdf` in library/ and reports
/// which ones don't actually start with `%PDF-`. The bug this catches:
/// a vendor (LCSC, Cloudflare-walled mfr sites, "click to download"
/// interstitial pages) silently returns an HTML body for what looks like
/// a 200 OK PDF response, the off-band download writes those bytes to
/// `<MPN>.pdf`, and downstream tools (ds2sf, chipsmith) then choke on
/// what's not a real PDF. Real example caught 2026-05-07: HR911105A.pdf
/// was 47 KB of <!DOCTYPE html> from an LCSC mirror that 404'd.
///
/// On --repair: deletes the bogus PDF + appends an `html_masquerade`
/// failure entry to info.json.fetched_via_chain so `adom-chip-fetcher
/// next-source` correctly advances to the next ladder step.
fn pdf_check_cmd(mpn_filter: Option<&str>, repair: bool) -> Result<()> {
let lib = library::root();
if !lib.exists() {
anyhow::bail!("library does not exist at {}", lib.display());
}
let mut total = 0usize;
let mut bogus: Vec<(String, usize, String)> = Vec::new(); // (mpn, size, first_80_bytes)
let mut clean = 0usize;
let mut missing = 0usize;
for entry in std::fs::read_dir(&lib)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() { continue; }
let mpn = match path.file_name().and_then(|n| n.to_str()) {
Some(m) => m.to_string(),
None => continue,
};
if let Some(filter) = mpn_filter {
if mpn != filter { continue; }
}
let pdf = path.join(format!("{mpn}.pdf"));
if !pdf.is_file() { missing += 1; continue; }
total += 1;
// Read first 80 bytes — enough for magic + diagnostic preview.
let mut head = [0u8; 80];
let read = match std::fs::File::open(&pdf)
.and_then(|mut f| std::io::Read::read(&mut f, &mut head))
{
Ok(n) => n,
Err(_) => continue,
};
let head_slice = &head[..read];
if head_slice.starts_with(b"%PDF-") {
clean += 1;
continue;
}
let size = std::fs::metadata(&pdf).map(|m| m.len() as usize).unwrap_or(0);
let preview: String = head_slice.iter().take(80)
.map(|b| if b.is_ascii() && !b.is_ascii_control() { *b as char } else { '·' })
.collect();
bogus.push((mpn.clone(), size, preview.clone()));
if repair {
let _ = std::fs::remove_file(&pdf);
// Try to record the failure on info.json.fetched_via_chain
// so next-source knows the prior datasheet attempt was bogus.
// Best-effort: pick the most recent successful chain entry's
// source as the failed one (since that's where the file came
// from), or fall back to "unknown" if chain is empty.
let chain = sourcing::read_chain(&mpn).unwrap_or_default();
let suspect_source = chain.iter()
.rev()
.find(|e| e.outcome == "OK" || e.outcome.contains("OK") || e.outcome == "lcsc_last_resort")
.map(|e| e.source.clone());
if let Some(s) = suspect_source {
let src_enum = match s.as_str() {
"manufacturer" => sourcing::Source::Manufacturer,
"snapmagic" => sourcing::Source::SnapMagic,
"mouser" => sourcing::Source::Mouser,
"digikey" => sourcing::Source::DigiKey,
"arrow" => sourcing::Source::Arrow,
"cse_ul" | "cse" => sourcing::Source::CseUl,
"lcsc" => sourcing::Source::Lcsc,
_ => sourcing::Source::Manufacturer,
};
let _ = sourcing::append_chain(&mpn, src_enum, "html_masquerade",
Some(&format!("PDF was actually HTML — first 80 bytes: {}", preview.chars().take(60).collect::<String>())));
}
}
}
println!("PDF check: {clean}/{total} real PDFs ({missing} chips have no PDF){}",
if repair { "; --repair active" } else { "" });
if !bogus.is_empty() {
println!();
println!("Bogus PDFs (HTML masquerading as %PDF-):");
for (mpn, sz, preview) in &bogus {
let action = if repair { " → DELETED" } else { "" };
println!(" ❌ {mpn} ({sz} bytes){action}");
println!(" first 60 bytes: {}", &preview[..preview.len().min(60)]);
}
println!();
if repair {
hints::print_hint("bogus PDFs deleted + info.json.fetched_via_chain updated. Run `adom-chip-fetcher next-source <MPN>` to see what to try next.");
} else {
hints::print_hint("re-run with --repair to delete bogus PDFs + record html_masquerade in fetched_via_chain. Then refetch from next ladder source.");
}
anyhow::bail!("found {} bogus PDF(s)", bogus.len());
}
Ok(())
}
/// Drive pup to a vendor page, find the real PDF anchor on the rendered
/// page (cookies + Cloudflare-passed Chrome fingerprint preserved), pull
/// the bytes via `fetch().arrayBuffer()` inside the browser context,
/// base64-transfer them back, magic-byte validate, save to
/// `library/<MPN>/<MPN>.pdf`. The escape hatch for vendors that detect
/// curl and serve HTML to it (Hanrun's Aliyun CDN, ST CDN, JST product
/// pages, etc.).
///
/// Decision matrix:
/// --pdf-url given → fetch that URL directly (most reliable)
/// --page-url given (no pdf-url) → navigate, find first PDF anchor matching MPN
/// neither given → use sourcing::guess_mfr to pick a search URL
fn pup_pdf_fetch_cmd(
mpn: &str,
page_url: Option<&str>,
pdf_url: Option<&str>,
source: &str,
session: &str,
) -> Result<()> {
let dir = library::root().join(mpn);
if !dir.is_dir() {
anyhow::bail!("no chip directory at {} — heartbeat or import once first to create it", dir.display());
}
let source_enum = match source.to_ascii_lowercase().as_str() {
"manufacturer" | "mfr" => sourcing::Source::Manufacturer,
"snapmagic" | "snapeda" => sourcing::Source::SnapMagic,
"mouser" => sourcing::Source::Mouser,
"digikey" => sourcing::Source::DigiKey,
"arrow" => sourcing::Source::Arrow,
"cse" | "cse_ul" | "ul" | "ultralibrarian" => sourcing::Source::CseUl,
"lcsc" => sourcing::Source::Lcsc,
other => anyhow::bail!("unknown source slug {other:?}"),
};
let _ = session; // legacy arg; native fetch uses the one adom-chip-fetcher window
// Resolve the PDF URL: prefer --pdf-url; else open --page-url (or the mfr
// product page) in the adom-chip-fetcher window's scrape tab and read the
// datasheet anchor off the rendered page. Everything stays in ONE window.
let resolved_pdf = if let Some(p) = pdf_url {
p.to_string()
} else {
let nav_url = page_url.map(|s| s.to_string())
.or_else(|| sourcing::guess_mfr(mpn).map(|g| g.search_url))
.ok_or_else(|| anyhow::anyhow!("pass --pdf-url or --page-url for {mpn}"))?;
let _ = hints::heartbeat(mpn, &format!("pup-pdf-fetch from {source}: finding datasheet"));
pup::scrape_navigate(&nav_url)?;
std::thread::sleep(std::time::Duration::from_secs(4));
let r = pup::scrape_eval(
"(function(){var a=[...document.querySelectorAll('a')].find(function(x){return /\\/resource\\/.*\\.pdf/i.test(x.href)||(/datasheet/i.test(x.textContent)&&/\\.pdf/i.test(x.href))});return a?a.href:''})()"
)?;
let url = r.as_str().unwrap_or("").to_string();
if url.is_empty() {
let _ = sourcing::append_chain(mpn, source_enum, "pup_no_pdf_link",
Some(&format!("no datasheet anchor on {nav_url}")));
anyhow::bail!("no datasheet link found on {nav_url}; pass --pdf-url <direct-url>");
}
println!("OK: found datasheet: {url}");
url
};
// Fetch the bytes THROUGH the signed-in browser (real cookies, no download,
// no native viewer) straight into the library.
let _ = hints::heartbeat(mpn, &format!("pup-pdf-fetch from {source}: downloading datasheet"));
let target = dir.join(format!("{mpn}.pdf"));
let (n, ctype) = pup::fetch_url_to_file(&resolved_pdf, &target)?;
let head: Vec<u8> = std::fs::read(&target).ok().map(|b| b.into_iter().take(4).collect()).unwrap_or_default();
if head != b"%PDF" {
std::fs::remove_file(&target).ok();
let _ = sourcing::append_chain(mpn, source_enum, "pup_fetch_not_pdf",
Some(&format!("content-type {ctype}, {n} bytes, head {:?}", String::from_utf8_lossy(&head))));
anyhow::bail!("fetched bytes are not a PDF (content-type {ctype}, {n} bytes)");
}
println!("OK: wrote {} ({} KB, {source})", target.display(), n / 1024);
let _ = sourcing::append_chain(mpn, source_enum, "OK",
Some(&format!("datasheet {} KB via fetch_url, %PDF verified", n / 1024)));
let _ = hints::heartbeat(mpn, "imported");
Ok(())
}
/// Tiny base64 decoder. Avoids pulling in the `base64` crate for one
/// call site. Spec: RFC 4648 + Standard alphabet, ignores whitespace.
fn base64_decode(s: &str) -> std::result::Result<Vec<u8>, String> {
const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut lookup = [255u8; 256];
for (i, &c) in ALPHA.iter().enumerate() { lookup[c as usize] = i as u8; }
let mut out = Vec::with_capacity(s.len() * 3 / 4);
let bytes: Vec<u8> = s.bytes().filter(|b| !b.is_ascii_whitespace() && *b != b'=').collect();
let mut i = 0;
while i + 3 < bytes.len() {
let (a, b, c, d) = (lookup[bytes[i] as usize], lookup[bytes[i+1] as usize],
lookup[bytes[i+2] as usize], lookup[bytes[i+3] as usize]);
if a == 255 || b == 255 || c == 255 || d == 255 {
return Err(format!("invalid char at byte {i}"));
}
out.push((a << 2) | (b >> 4));
out.push((b << 4) | (c >> 2));
out.push((c << 6) | d);
i += 4;
}
// Tail bytes (handles 2- and 3-byte residue without padding).
if i + 1 < bytes.len() {
let a = lookup[bytes[i] as usize];
let b = lookup[bytes[i+1] as usize];
out.push((a << 2) | (b >> 4));
if i + 2 < bytes.len() {
let c = lookup[bytes[i+2] as usize];
out.push((b << 4) | (c >> 2));
}
}
Ok(out)
}
/// Re-fetch driver. Opens the canonical URL for (source, MPN) in the
/// adom-chip-fetcher-scrape pup session, sets up a desktop_watch_files for the
/// expected download, waits up to `timeout` seconds, then imports the
/// resulting file with magic-byte validation. Records every attempt on
/// `info.json.fetched_via_chain` so the audit trail shows which sources
/// were probed in what order.
fn refetch_cmd(mpn: &str, format: &str, source: &str, url_only: bool, timeout: u64) -> Result<()> {
let dir = library::root().join(mpn);
if !dir.is_dir() {
anyhow::bail!("no chip directory at {}", dir.display());
}
let source_enum = match source.to_ascii_lowercase().as_str() {
"manufacturer" | "mfr" => sourcing::Source::Manufacturer,
"snapmagic" | "snapeda" => sourcing::Source::SnapMagic,
"mouser" => sourcing::Source::Mouser,
"digikey" => sourcing::Source::DigiKey,
"arrow" => sourcing::Source::Arrow,
"cse" | "cse_ul" | "ul" | "ultralibrarian" => sourcing::Source::CseUl,
"lcsc" => sourcing::Source::Lcsc,
other => anyhow::bail!("unknown source slug {other:?}; valid: manufacturer, snapmagic, mouser, digikey, arrow, cse_ul, lcsc"),
};
let url = canonical_source_url(mpn, source_enum)?;
println!("OK: refetch {mpn} format={format} source={source}");
println!("URL: {url}");
if url_only {
// Used by validate.rs::attempt_refetch_library so the autonomous
// loop can record what would be tried without opening a browser
// window in the middle of validation. Returns the URL via stdout
// for the caller; chain entry written below.
let _ = sourcing::append_chain(mpn, source_enum, "refetch_url_resolved",
Some(&format!("format={format}, url={url}")));
return Ok(());
}
// Real refetch — open URL, watch downloads, import.
let _ = hints::heartbeat(mpn, &format!("refetch {format} from {source}: opening pup"));
let body = serde_json::json!({
"sessionId": "adom-chip-fetcher-scrape",
"profile": "adom-chip-fetcher",
"url": url,
}).to_string();
let out = std::process::Command::new("adom-desktop")
.args(["browser_open_window", &body])
.output()?;
if !out.status.success() {
let _ = sourcing::append_chain(mpn, source_enum, "refetch_failed",
Some(&format!("could not open pup window for {url}")));
anyhow::bail!("could not open pup window: {}", String::from_utf8_lossy(&out.stderr));
}
println!("watching ~/Downloads for {timeout}s — click the source's download button to land the file");
let glob = match format {
"kicad_sym" | "kicad_mod" | "eagle" | "all" => "*.zip",
"step" => "*.step",
"pdf" => "*.pdf",
_ => "*",
};
let watch_body = serde_json::json!({
"path": "Downloads",
"glob": glob,
"timeoutMs": timeout * 1000,
}).to_string();
let watch_out = std::process::Command::new("adom-desktop")
.args(["desktop_watch_files", &watch_body])
.output()?;
if !watch_out.status.success() {
let _ = sourcing::append_chain(mpn, source_enum, "refetch_timeout",
Some(&format!("desktop_watch_files exit non-zero (likely user did not click within {timeout}s)")));
anyhow::bail!("desktop_watch_files timed out for {url}");
}
let watch_v: serde_json::Value = serde_json::from_slice(&watch_out.stdout)?;
let landed = watch_v.get("files")
.and_then(|f| f.as_array())
.and_then(|a| a.first())
.and_then(|f| f.get("path"))
.and_then(|p| p.as_str())
.ok_or_else(|| anyhow::anyhow!("desktop_watch_files returned no file path"))?;
println!("OK: refetch landed {landed}");
let pulled = pup::pull_file(landed, dir.parent().unwrap_or(&dir).to_str().unwrap())?;
let report = import::import_path(&pulled, Some(mpn))?;
println!("imported into {} (+{} files)", report.dest_dir.display(), report.files_added.len());
let _ = sourcing::append_chain(mpn, source_enum, "OK",
Some(&format!("refetched {format} via refetch_cmd, imported {} files", report.files_added.len())));
Ok(())
}
/// Map (source, MPN) → canonical URL. Manufacturer uses the prefix table
/// from sourcing::guess_mfr (so it's the same URL as `adom-chip-fetcher
/// mfr-probe`); third-party sources have hardcoded search URL templates.
fn canonical_source_url(mpn: &str, source: sourcing::Source) -> Result<String> {
let enc = mpn.replace(' ', "%20");
Ok(match source {
// The wiki is pulled via its API (sources::wiki), not by opening a vendor
// page in pup — this is just the human search URL for completeness.
sourcing::Source::AdomWiki => format!("https://wiki.adom.inc/search?q={enc}"),
sourcing::Source::Manufacturer => {
sourcing::guess_mfr(mpn)
.map(|g| g.search_url)
.ok_or_else(|| anyhow::anyhow!("could not infer manufacturer for {mpn}; pass an explicit URL via --source mouser/digikey/etc."))?
}
sourcing::Source::SnapMagic => format!("https://www.snapeda.com/search/?q={enc}&active=part"),
sourcing::Source::Mouser => format!("https://www.mouser.com/c/?q={enc}"),
sourcing::Source::DigiKey => format!("https://www.digikey.com/en/products/result?keywords={enc}"),
sourcing::Source::Arrow => format!("https://www.arrow.com/en/categories/connectors?q={enc}"),
sourcing::Source::CseUl => format!("https://componentsearchengine.com/search/{enc}"),
sourcing::Source::Lcsc => format!("https://www.lcsc.com/search?q={enc}"),
})
}
/// Validate driver. Resolves MPN → chip directory under library/<MPN>/,
/// runs the ds2sf → concur loop, prints the outcome. Returns non-zero
/// only on hard error or when the loop ends in NeedsHuman /
/// Ds2sfUnrecoverable — so a CI-style caller can `&& adom-chip-fetcher
/// validate <MPN>` and trust the exit code.
fn validate_cmd(mpn: &str) -> Result<()> {
let dir = library::root().join(mpn);
if !dir.is_dir() {
anyhow::bail!("no chip directory at {} — run adom-chip-fetcher first", dir.display());
}
let outcome = validate::validate_chip_dir(&dir)?;
validate::print_outcome(&outcome);
match outcome {
validate::ValidationOutcome::Golden { .. } |
validate::ValidationOutcome::GoldenWithVariants { .. } => Ok(()),
validate::ValidationOutcome::NeedsHuman { reason, .. } => {
anyhow::bail!("validation needs human review: {reason}");
}
validate::ValidationOutcome::Ds2sfUnrecoverable { summary, .. } => {
anyhow::bail!("ds2sf unrecoverable: {}", summary.unwrap_or_default());
}
}
}
/// Manufacturer-probe. Detects the manufacturer from an MPN prefix and
/// opens their canonical product/search page in the adom-chip-fetcher-scrape
/// pup session. Records the attempt on info.json.fetched_via_chain so
/// next-source knows manufacturer has been tried. AI must run this
/// BEFORE any aggregator — that's the rule, now enforced.
fn mfr_probe_cmd(mpn: &str, no_open: bool) -> Result<()> {
let guess = sourcing::guess_mfr(mpn);
match guess {
Some(g) => {
println!("OK: detected manufacturer {} for {mpn}", g.manufacturer);
println!("URL: {}", g.search_url);
// Record the attempt as a navigation event (no outcome yet — the
// AI fills in the outcome when it heartbeats success/empty).
let _ = sourcing::append_chain(mpn, sourcing::Source::Manufacturer,
"attempted",
Some(&format!("auto-probe → {}", g.manufacturer)));
if !no_open {
// Manufacturer-first source opens as a TAB in the ONE adom-chip-fetcher
// window (beside the dashboard tab), via the native browser when
// available so ST/TI/Nordic logins + trust apply.
match pup::open_site(SESSION, PROFILE, &g.search_url) {
Ok(_) => println!("OK: opened {} in the adom-chip-fetcher window ({})", g.search_url, pup::surface_label()),
Err(e) => hints::print_hint(&format!("could not auto-open ({e}) — open the URL above manually")),
}
}
hints::print_hint(&format!("walk the page (Documentation, Tools & Software, CAD icon, Resources). When you decide whether the mfr has the CAD or not, heartbeat with `adom-chip-fetcher heartbeat {mpn} \"manufacturer: <result>\"` so the chain auto-updates."));
Ok(())
}
None => {
println!("WARN: could not infer manufacturer for {mpn} from prefix table.");
hints::print_hint(&format!("identify the manufacturer manually (datasheet header, distributor product page) and open <mfr>.com directly. Then heartbeat: `adom-chip-fetcher heartbeat {mpn} \"probing <vendor>.com\"` so the chain records it."));
Ok(())
}
}
}
/// Print the next ladder step that hasn't been tried, with the canonical
/// search URL where applicable. Reads info.json.fetched_via_chain and
/// applies the strict order rule (Mfr → SnapMagic → Mouser → DigiKey →
/// Arrow → CSE/UL → LCSC). LCSC is only suggested when every prior step
/// has a recorded failure outcome.
fn next_source_cmd(mpn: &str) -> Result<()> {
let chain = sourcing::read_chain(mpn)?;
println!("Sourcing chain so far for {mpn} ({} entries):", chain.len());
for e in &chain {
let note = e.note.as_deref().unwrap_or("");
println!(" {} → {} {}", e.source, e.outcome, if note.is_empty() { "" } else { "—" });
if !note.is_empty() { println!(" {note}"); }
}
println!();
match sourcing::next_source(mpn)? {
Some(s) => {
println!("NEXT: try {} next.", s.as_slug());
if matches!(s, sourcing::Source::Manufacturer) {
hints::print_hint(&format!("`adom-chip-fetcher mfr-probe {mpn}` will detect the manufacturer + open their page in pup."));
} else if matches!(s, sourcing::Source::Lcsc) {
hints::print_hint(&format!("LCSC is the LAST step — only fall here when every prior step has a recorded failure. Surface 'no manufacturer CAD found' to the user before pulling LCSC."));
} else {
hints::print_hint(&format!("heartbeat `adom-chip-fetcher heartbeat {mpn} \"trying {}\"` before navigating, then heartbeat the outcome (\"empty\" / \"OK\" / \"404\") so next-source advances correctly.", s.as_slug()));
}
}
None => {
println!("NEXT: ladder fully exhausted for {mpn}. No more steps to try.");
hints::print_hint("if the chip is still incomplete, surface 'no CAD available anywhere' to the user — don't claim done.");
}
}
Ok(())
}
/// Hard gate. Walks the library, computes each chip's completeness against
/// the sourcing-ladder rule, prints HINT lines per missing artifact, and
/// returns non-zero exit when any card is incomplete. The AI cannot honestly
/// claim "done" without this returning zero — that's the point.
fn backfill_provenance_cmd(mpn_filter: Option<&str>) -> Result<()> {
let lib = inventory::scan()?;
let mut fixed = 0usize;
let mut still_unknown: Vec<(String, Vec<String>)> = Vec::new();
for c in &lib.chips {
if mpn_filter.is_some_and(|m| c.mpn != m) { continue; }
let dir = library::root().join(&c.mpn);
let (recovered, unknown) = crate::import::backfill_dir(&dir, &c.mpn)?;
if !recovered.is_empty() {
fixed += 1;
println!(" {} → recovered source for: {}", c.mpn, recovered.join(", "));
}
if !unknown.is_empty() {
still_unknown.push((c.mpn.clone(), unknown));
}
}
println!();
println!("backfill: recovered provenance on {fixed} chip(s).");
if !still_unknown.is_empty() {
println!();
println!("Source genuinely UNRECORDED (no signal to derive from — refetch to capture):");
for (mpn, files) in &still_unknown {
println!(" {mpn}: {}", files.join(", "));
}
}
Ok(())
}
fn gate_cmd(strict: bool, mpn_filter: Option<&str>) -> Result<()> {
let lib = inventory::scan()?;
let chips: Vec<_> = lib.chips.into_iter()
.filter(|c| mpn_filter.is_none_or(|m| c.mpn == m))
.collect();
if chips.is_empty() {
if let Some(m) = mpn_filter {
anyhow::bail!("no chip in library named {m:?}");
}
println!("library is empty.");
return Ok(());
}
let mut incomplete: Vec<(String, Vec<String>)> = Vec::new();
let mut complete = 0usize;
for c in &chips {
let dir = library::root().join(&c.mpn);
let pdf = dir.join(format!("{}.pdf", c.mpn));
let mut missing: Vec<String> = Vec::new();
if !c.has_pdf { missing.push("datasheet.pdf".into()); }
if !c.has_step { missing.push("STEP".into()); }
if !c.has_sym { missing.push("KiCad .kicad_sym".into()); }
if !c.has_mod { missing.push("KiCad .kicad_mod".into()); }
// PERMANENT GUARANTEE 1 — the datasheet must be REAL (multi-page + pinout),
// not a 1-page denial page that silently breaks ds2sf.
if c.has_pdf {
if let Err(reason) = crate::import::datasheet_ready(&pdf) {
missing.push(format!("GOOD datasheet — {reason}"));
}
}
// PERMANENT GUARANTEE 2 — ds2sf (the most authoritative source: the
// manufacturer datasheet extraction) must have RUN and SUCCEEDED.
let ds_ok = std::fs::read_to_string(dir.join(format!("{}-extraction.result.json", c.mpn))).ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|r| r.get("status").and_then(|x| x.as_str()).map(|s| s == "ok"))
.unwrap_or(false);
if !ds_ok { missing.push("ds2sf extraction (symbol+footprint from the datasheet)".into()); }
if strict {
if !c.has_altium { missing.push("Altium (.SchLib+.PcbLib OR .IntLib)".into()); }
if !c.has_fusion_lbr { missing.push("Fusion .lbr".into()); }
// Pin-1 alignment: the 3D chip's pin-1 must be lined up to the
// footprint's pin-1 and signed off in chipsmith (writes
// <mpn>.chipsmith.json). Only required once both a STEP + footprint
// exist (you can't align without both).
if c.has_step && c.has_mod && !c.pin1_signed {
missing.push("pin-1 alignment (open chipsmith → Align pin-1 → sign off)".into());
}
}
// A pin-1 corner MISMATCH (signed chip corner != the footprint's pin-1
// corner) means the 3D model is misaligned to the footprint — a real
// defect, so flag it in BOTH modes, not just strict.
if let (true, Some(cc), Some(fp)) = (c.pin1_signed, c.pin1_corner.as_deref(), c.fp_pin1_corner.as_deref()) {
if cc != fp {
missing.push(format!("pin-1 MISMATCH: chip {cc} vs footprint {fp} (re-align in chipsmith)"));
}
}
// PERMANENT GUARANTEE 3 — every file we ship must know where it came
// from. A file present on disk with no recorded source is a lazy fetch.
// `backfill-provenance` recovers what it can; whatever's left is real.
let unprov = crate::import::unprovenanced_files(&dir, &c.mpn);
if !unprov.is_empty() {
missing.push(format!("provenance for {} (run `backfill-provenance`, then refetch any still unknown)", unprov.join(", ")));
}
if missing.is_empty() {
complete += 1;
} else {
incomplete.push((c.mpn.clone(), missing));
}
}
let total = chips.len();
println!("GATE: {complete}/{total} cards complete{}", if strict { " (STRICT)" } else { "" });
println!();
if !incomplete.is_empty() {
println!("Incomplete cards — DO NOT claim done while these exist:");
for (mpn, missing) in &incomplete {
println!(" {mpn}: missing {}", missing.join(", "));
}
println!();
for (mpn, missing) in &incomplete {
for m in missing {
hints::print_hint(&format!("{mpn} → fetch {m} (see `adom-chip-fetcher sources` for the ladder, then `adom-chip-fetcher heartbeat {mpn} \"<stage>\"` per stage)"));
}
}
println!();
hints::print_hint(&format!("re-run `adom-chip-fetcher gate{}` after each round of work — it returns 0 only when every card is complete.",
if strict { " --strict" } else { "" }));
anyhow::bail!("gate FAILED: {} of {total} cards incomplete", incomplete.len());
}
println!("OK: all {total} cards complete{}.", if strict { " (STRICT)" } else { "" });
Ok(())
}
fn sources_cmd() -> Result<()> {
// Single source of truth for the sourcing ladder. Both this command and
// hints::sourcing_ladder_lines() read from here, so playbook + binary stay
// in sync. AI consumers can call `adom-chip-fetcher sources` and parse the
// ordered list — no need to re-read the playbook prose.
println!("Sourcing-ladder priority (always go in order, never skip):\n");
for (i, (name, note)) in hints::SOURCE_LADDER.iter().enumerate() {
println!(" {}. {:<14} {}", i + 1, name, note);
}
println!();
hints::print_hint("LCSC is LAST RESORT — avoid if possible. Surface 'no manufacturer CAD' to user before reaching for LCSC.");
hints::print_hint("Stock data (stock.json) comes from Mouser regardless of where CAD/PDFs land.");
Ok(())
}
fn hint_cmd() -> Result<()> {
match hints::last() {
None => {
hints::print_hint("no heartbeat yet — kick off with `adom-chip-fetcher heartbeat <MPN> \"starting fetch\"`");
hints::print_hint("then `adom-chip-fetcher selfcheck` to verify dashboard + pup session.");
}
Some(hb) => {
let age = hints::age_secs(&hb);
println!("last heartbeat: {} → {:?} ({}s ago)", hb.mpn, hb.stage, age);
if age > 60 {
hints::print_hint(&format!(
"stale heartbeat — refresh: `adom-chip-fetcher heartbeat {} \"<current stage>\"`",
hb.mpn
));
} else {
// If the stage is one of our canonical post-action labels, suggest
// the next concrete command. Otherwise fall back to a generic prompt
// so this is never silent — the AI always gets a next step.
let canonical = matches!(hb.stage.as_str(),
"fetched" | "imported" | "pulled" | "listed" | "status" | "login");
if canonical {
hints::print_next_after(&hb.stage, Some(&hb.mpn));
} else {
hints::print_hint(&format!(
"heartbeat the next stage transition: `adom-chip-fetcher heartbeat {} \"<next stage>\"`",
hb.mpn
));
hints::print_hint(&format!(
"after import lands: `adom-chip-fetcher heartbeat {} imported` then `adom-chip-fetcher status {}`",
hb.mpn, hb.mpn
));
hints::print_hint("if anything is off-rails: `adom-chip-fetcher selfcheck`");
}
}
}
}
Ok(())
}
fn app_cmd(port: u16) -> Result<()> {
use std::sync::mpsc;
let (tx, rx) = mpsc::channel::<()>();
let handle = std::thread::spawn(move || {
// Signal "ready" once we're about to bind. The bind itself is fast.
let _ = tx.send(());
let _ = serve::run(port);
});
let _ = rx.recv();
std::thread::sleep(std::time::Duration::from_millis(300));
serve::open_in_hydrogen(port)?;
let _ = handle.join();
Ok(())
}
fn install_cmd() -> Result<()> {
let home = std::env::var("HOME").map_err(|_| anyhow::anyhow!("HOME not set"))?;
let skill_dir = std::path::PathBuf::from(home).join(".claude/skills/adom-chip-fetcher");
std::fs::create_dir_all(&skill_dir)?;
let skill_path = skill_dir.join("SKILL.md");
std::fs::write(&skill_path, SKILL_MD)?;
println!("OK: adom-chip-fetcher SKILL.md deployed to {}", skill_path.display());
println!(" {} bytes, {} lines", SKILL_MD.len(), SKILL_MD.lines().count());
// Deploy playbooks/ alongside SKILL.md. Each playbook is a focused deep-dive Claude
// reads on demand — keeping SKILL.md lean enough to survive context-window compaction.
let playbooks_dir = skill_dir.join("playbooks");
std::fs::create_dir_all(&playbooks_dir)?;
let mut total_bytes: usize = 0;
let mut total_lines: usize = 0;
for (name, body) in PLAYBOOKS {
let pb_path = playbooks_dir.join(name);
std::fs::write(&pb_path, body)?;
total_bytes += body.len();
total_lines += body.lines().count();
}
println!(
"OK: {} playbook(s) deployed to {}",
PLAYBOOKS.len(),
playbooks_dir.display()
);
println!(" {total_bytes} bytes, {total_lines} lines across all playbooks");
println!();
println!("Try `adom-chip-fetcher --help` to see commands.");
Ok(())
}
fn pull_cmd(remote_path: &str, mpn: Option<&str>, no_validate: bool, discovery_source: &str, content_origin: &str, source_url: &str) -> Result<()> {
let incoming = library::root().parent().map(|p| p.join("incoming")).unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
std::fs::create_dir_all(&incoming)?;
println!("pulling {remote_path} → {}", incoming.display());
let local = pup::pull_file(remote_path, incoming.to_str().unwrap())?;
println!("pulled to {}", local.display());
let prov = if discovery_source != "unknown" || content_origin != "unknown" || !source_url.is_empty() {
Some(import::FileProvenance { discovery_source: discovery_source.to_string(), content_origin: content_origin.to_string(), url: source_url.to_string() })
} else { None };
let report = import::import_path_with_provenance(&local, mpn, prov.as_ref())?;
println!("imported into {}", report.dest_dir.display());
for f in &report.files_added { println!(" + {f}"); }
if let Some(m) = mpn.or_else(|| report.dest_dir.file_name().and_then(|s| s.to_str())) {
let _ = hints::heartbeat(m, "imported");
// Validate-then-thumbnail order — ds2sf may patch info.json.package
// (e.g. VQFN-32 → VQFN-40), and thumbnails should reflect the
// corrected metadata, not the pre-patch one.
if !no_validate {
run_post_import_validate(&report.dest_dir, m);
}
auto_render_thumbnails(m);
hints::print_next_after("pulled", Some(m));
}
Ok(())
}
fn import_cmd(path: &std::path::Path, mpn: Option<&str>, no_validate: bool, discovery_source: &str, content_origin: &str, source_url: &str) -> Result<()> {
let prov = if discovery_source != "unknown" || content_origin != "unknown" || !source_url.is_empty() {
Some(import::FileProvenance { discovery_source: discovery_source.to_string(), content_origin: content_origin.to_string(), url: source_url.to_string() })
} else { None };
let report = import::import_path_with_provenance(path, mpn, prov.as_ref())?;
println!("imported into {}", report.dest_dir.display());
for f in &report.files_added {
println!(" + {f}");
}
if report.files_added.is_empty() {
println!(" (no recognized files found in {})", path.display());
}
if let Some(m) = mpn.or_else(|| report.dest_dir.file_name().and_then(|s| s.to_str())) {
let _ = hints::heartbeat(m, "imported");
if !no_validate {
run_post_import_validate(&report.dest_dir, m);
}
auto_render_thumbnails(m);
hints::print_next_after("imported", Some(m));
}
Ok(())
}
/// Drive the validation pipeline (ds2sf + concur with autonomous-repair
/// loop) after a fresh file lands. Best-effort — failures here surface as
/// WARN; they don't unwind the import. The `--no-validate` flag bypasses
/// this for cases where the user is dropping individual files mid-fetch.
fn run_post_import_validate(dir: &std::path::Path, mpn: &str) {
println!();
println!("running validation (ds2sf + concur) — gold-dataset gate");
match validate::validate_chip_dir(dir) {
Ok(outcome) => validate::print_outcome(&outcome),
Err(e) => println!("WARN: validate failed for {mpn}: {e} — re-run with `adom-chip-fetcher validate {mpn}`"),
}
}
/// Best-effort thumbnail re-render for one MPN immediately after import.
/// Errors are swallowed (printed as WARN) — the import already succeeded;
/// thumbnails are a UX nicety, not a correctness gate.
fn auto_render_thumbnails(mpn: &str) {
match thumbnails::generate_for(mpn, false) {
Ok(r) if !r.generated.is_empty() => {
println!(" thumbnails: +{}", r.generated.join(" +"));
let _ = hints::heartbeat(mpn, "thumbnails ready");
}
Ok(_) => {} // nothing changed
Err(e) => println!("WARN: thumbnail render failed for {mpn}: {e}"),
}
}
/// Ladder step 0 in isolation: query the Adom Wiki and reuse a published bundle
/// if one exists. Falls through (prints a clear message) when there's no match,
/// so it doubles as the fall-through test without touching the vendor ladder.
fn wiki_fetch_cmd(mpn: &str) -> Result<()> {
library::ensure_dir(mpn)?;
match crate::sources::wiki::try_fetch(mpn)? {
Some(pull) => {
println!("OK: reused Adom Wiki bundle ({} files) from {}", pull.files_added.len(), pull.url);
for f in &pull.files_added {
println!(" + {f}");
}
let _ = thumbnails::generate_for(mpn, true);
println!("\nReused from the Adom Wiki — no external fetch needed.");
}
None => {
println!("Adom Wiki: no published bundle for {mpn}.");
hints::print_hint(&format!("not on the wiki — run `adom-chip-fetcher fetch {mpn}` to walk the vendor ladder (manufacturer → SnapMagic → …)."));
}
}
Ok(())
}
fn fetch(mpn: &str) -> Result<()> {
library::ensure_dir(mpn)?;
// Ladder step 0 — the Adom Wiki. If this part is already published with a
// complete bundle, reuse it (free, fast, Adom-consistent) and skip the
// external vendors entirely. Reads only; no token. On no match (or only a
// metadata-only page with no CAD files) we fall straight through to the
// vendor ladder below — the existing ladder is untouched.
match crate::sources::wiki::try_fetch(mpn) {
Ok(Some(pull)) => {
println!("OK: reused Adom Wiki bundle ({} files) from {}", pull.files_added.len(), pull.url);
for f in &pull.files_added {
println!(" + {f}");
}
let _ = hints::heartbeat(mpn, "imported (Adom Wiki)");
let _ = thumbnails::generate_for(mpn, true);
println!("\nReused from the Adom Wiki — no external fetch needed. Run `adom-chip-fetcher gate --mpn {mpn}` to confirm completeness.");
return Ok(());
}
Ok(None) => println!("Adom Wiki: no published bundle for {mpn} — falling through to the vendor ladder."),
Err(e) => println!("WARN: Adom Wiki lookup failed ({e}) — falling through to the vendor ladder."),
}
// Rail: dashboard MUST be visible before a fetch starts. Auto-open if not.
// Best-effort — a failure here doesn't block the fetch (user might be
// running headless), but they'll see a WARN.
if let Err(e) = hints::dashboard_ensure(false) {
println!("WARN: dashboard_ensure failed: {e}");
}
let _ = hints::heartbeat(mpn, "starting fetch");
let url = sources::ti::cad_url(mpn);
println!("opening {url}");
pup::open_window(SESSION, PROFILE, &url)?;
std::thread::sleep(std::time::Duration::from_secs(3));
let title_v = pup::eval(SESSION, "document.title")?;
let title = title_v.as_str().unwrap_or("(unknown)");
println!("page title: {title}");
println!("\nstep 1: on the TI page, click \"CAD/CAE Symbols & Footprints\" → opens UL with the part loaded");
println!("step 2: in UL — pick variant, check STEP + KiCAD v6+, set units to mm, click Submit");
println!("step 3: adom-chip-fetcher will auto-pull the download (up to 5 min wait)");
println!();
let local = match wait_for_download() {
Ok(p) => p,
Err(e) => {
eprintln!("auto-pull failed: {e}");
eprintln!("fallback: drag the zip to adom-chip-fetcher/incoming/ and run `adom-chip-fetcher import`");
return Err(e);
}
};
println!("pulled to {}", local.display());
// Derive provenance from the last chain entry. The fetch function
// currently drives TI/UL, but the chain records what actually happened.
let last_source = crate::sourcing::last_chain_source(mpn).unwrap_or_else(|| "unknown".to_string());
let prov = import::FileProvenance {
discovery_source: last_source.clone(),
content_origin: last_source,
url: String::new(),
};
let report = import::import_path_with_provenance(&local, Some(mpn), Some(&prov))?;
println!("imported into {}", report.dest_dir.display());
for f in &report.files_added { println!(" + {f}"); }
let _ = hints::heartbeat(mpn, "imported");
// Validate FIRST, thumbnails SECOND. ds2sf may patch info.json (e.g.
// package family correction VQFN-32 → VQFN-40), and the thumbnail
// render reads info.json for orientation hints + label fields — so
// thumbnails should run against the patched metadata, not the
// pre-patch one. Closes gap #3 from the integration design doc.
println!();
println!("running validation (ds2sf + concur) — this is the agent-to-agent gate downstream tools rely on");
let dir = library::root().join(mpn);
match validate::validate_chip_dir(&dir) {
Ok(outcome) => validate::print_outcome(&outcome),
Err(e) => println!("WARN: validate failed: {e} — re-run with `adom-chip-fetcher validate {mpn}`"),
}
auto_render_thumbnails(mpn);
hints::print_next_after("fetched", Some(mpn));
Ok(())
}
/// Block until a fresh `ul_*.zip` lands in the user's Windows Downloads folder,
/// then pull it to the container's `incoming/`. Uses adom-desktop v1.5+'s
/// native `desktop_watch_files` — no shell, no PowerShell, no user-approval
/// dialog. Times out after 5 minutes.
fn wait_for_download() -> Result<std::path::PathBuf> {
println!("watching desktop ~/Downloads for ul_*.zip (up to 5 min)...");
let v = pup::desktop_watch_files(
"%USERPROFILE%\\Downloads",
"ul_*.zip",
300_000, // 5 min in ms
)?;
let ok = v.get("ok").and_then(|b| b.as_bool()).unwrap_or(false);
if !ok {
let hint = v.get("_hint").and_then(|s| s.as_str()).unwrap_or("");
anyhow::bail!("desktop_watch_files timed out: {hint}");
}
let remote = v
.get("file")
.and_then(|f| f.get("path"))
.and_then(|p| p.as_str())
.ok_or_else(|| anyhow::anyhow!("desktop_watch_files returned no file.path: {v}"))?
.to_string();
println!("desktop has {remote}");
let incoming = library::root()
.parent()
.map(|p| p.join("incoming"))
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
std::fs::create_dir_all(&incoming)?;
pup::pull_file(&remote, incoming.to_str().unwrap())
}
const SOURCES: &[(&str, &str, &str)] = &[
("google", "Google account (covers all Sign-in-with-Google flows)", "https://accounts.google.com/signin"),
("ti", "Texas Instruments", "https://www.ti.com/securemyprofile/login"),
("st", "STMicroelectronics", "https://my.st.com/cas/login"),
("microchip", "Microchip", "https://www.microchip.com/en-us/my-account-sign-in"),
("ultralib", "Ultra Librarian", "https://app.ultralibrarian.com/account/login"),
("snapeda", "SnapMagic / SnapEDA", "https://www.snapmagic.com/account/login"),
("componentsearch", "Component Search Engine (samacsys)", "https://componentsearchengine.com/account/login"),
];
fn login(source: &str) -> Result<()> {
if source == "all" {
println!("opening login pages for all sources as tabs in profile '{PROFILE}'");
for (name, _, url) in SOURCES {
println!(" → {name}: {url}");
// First call ensures the window exists; subsequent calls add tabs
let _ = pup::open_tab(SESSION, url);
// Fall back to open_window if the session doesn't exist yet
pup::open_window(SESSION, PROFILE, url)?;
}
println!("\nlog in to each, then 'Stay signed in' / Smart Lock will keep cookies for ~30 days.");
return Ok(());
}
if source == "list" {
println!("known sources:");
for (name, desc, url) in SOURCES {
println!(" {name:<18} {desc}");
println!(" {:<18} {url}", "");
}
return Ok(());
}
let entry = SOURCES.iter().find(|(n, _, _)| {
*n == source
|| (source == "ultralibrarian" && *n == "ultralib")
|| (source == "snapmagic" && *n == "snapeda")
|| (source == "samacsys" && *n == "componentsearch")
});
let url = match entry {
Some((_, _, u)) => *u,
None => anyhow::bail!(
"unknown source '{source}' — run `adom-chip-fetcher login list` to see available sources, or `adom-chip-fetcher login all` to open every source"
),
};
println!("opening {url} for login — cookies will persist in profile '{PROFILE}'");
pup::open_window(SESSION, PROFILE, url)?;
hints::print_next_after("login", None);
Ok(())
}
fn list() -> Result<()> {
let entries = library::scan()?;
if entries.is_empty() {
println!("library is empty ({})", library::root().display());
hints::print_hint("kick off a first fetch: `adom-chip-fetcher fetch <MPN>`");
return Ok(());
}
println!("{} parts in {}", entries.len(), library::root().display());
for e in entries {
println!(" {:<24} step={} mod={} sym={} pdf={}", e.mpn, mark(e.has_step), mark(e.has_mod), mark(e.has_sym), mark(e.has_pdf));
}
hints::print_next_after("listed", None);
Ok(())
}
fn status(mpn: &str) -> Result<()> {
let e = library::status(mpn)?;
println!("{:<24} step={} mod={} sym={} pdf={}", e.mpn, mark(e.has_step), mark(e.has_mod), mark(e.has_sym), mark(e.has_pdf));
hints::print_next_after("status", Some(mpn));
Ok(())
}
fn mark(present: bool) -> &'static str {
if present { "✓" } else { "·" }
}