← Commit history
CHANGELOG.md+22
@@ -1,5 +1,27 @@ # Changelog +## 0.2.56 (2026-10-21)+- **`altium_bom`: the project's BOM, read straight from Altium.**+  - Reads every schematic sheet the `.PrjPcb` lists, managed device sheets included, with each part's parameters.+  - Merges the board's placement and an optional variant (Not Fitted parts become DNP; alternates are listed).+  - Groups one line per manufacturer part number. It matches parameter names across libraries: `MPN` = `Manufacturer Part Number` = `PartNumber`, plus the Mouser, DigiKey and LCSC spellings.+  - Checks what an order needs: missing MPNs and distributor numbers, one part number on two footprints, board-only and sheet-only parts, DNP, and parts that stay out of a BOM.+  - Writes `bom.csv`, `bom-jlcpcb.csv` + `cpl-jlcpcb.csv`, `bom-mouser.csv`, `bom-digikey.csv` and `bom.json`.+  - Verified on AD 26.11.1: the TPS54202 buck (17 lines in 0.5 s), Altium's Mini PC example (46 sheets, 1046 parts, 132 lines in 19 s) and Bluetooth Sentinel (15 sheets including device sheets).+- **`altium_sheet_params`** writes parameters onto placed parts, finding each designator on whichever sheet holds it, and saves the sheets. It is the BOM write-back.+- **`altium-bom`** (container CLI, installed with the package):+  - `read` runs `altium_bom` and saves the result.+  - `price` makes one `adom-parts-search` call per line and reports stock against the boards needed, price, lead time, lifecycle and JLCPCB-only parts, plus a cheapest in-stock total.+  - `fill` writes the missing distributor numbers back onto the sheets.+  - New skill: **altium-bom**.+- `schematic_data`:+  - Also reports each part's `kind` (ComponentKind), `itemGuid`, `vaultGuid` and `sourceLibrary`, and its `description`.+  - A workspace-managed part's description is not read: on Altium's own Content Vault parts that call never returned.+  - New `paramFilter` and `paramNamesOnly` options read only the values a caller needs; one example IC's three preview-image parameters took minutes to read.+- **The agent's JSON escaping is linear.** It appended one character at a time, which is quadratic in DelphiScript; every verb that returns long text gets faster.+- The `altium-wiki-import` skill no longer says the Cloud Library cannot export Altium: it can, without distributor numbers, and the skill says how to add them.+- `uninstall.sh` also removes the `altium-*` container commands.+ ## 0.2.55 (2026-10-20) - **The edge rule no longer flags silkscreen** (#26). `set_design_rules` takes `edgeClearance` from the fab pack, where it means copper to the board edge (KiCad's `edge_clearance`). When the board had no outline-clearance rule, it created "Adom edge" scoped to everything, so Altium's DRC flagged silkscreen near the edge too, such as AI Flow's contact frames 0.12 mm in. The new rule is scoped `OnCopper`, and an existing "Adom edge" still scoped `All` is re-scoped the next time the rules are set. Silkscreen keeps Altium's own silk rules. - Verified live on Altium Designer 26.11.1, on two copies of the same saved board with the gap widened to 0.6 mm so silkscreen near the edge is in reach: scope `All` gave 28 edge violations, `OnCopper` gave 5 (the copper). Every other rule's count was identical (137 silk-to-mask, 48 silk-to-silk, 8 mask sliver).
bridge-rs/src/bom.rsadded+473
@@ -0,0 +1,473 @@+//! The project BOM Altium itself does not give an AI: every sheet's placed parts with their+//! parameters, the board's placement, variants, grouped by part number, checked for what an order+//! needs, and written as the files distributors and assemblers take (generic, JLCPCB BOM + CPL,+//! Mouser and DigiKey BOM upload). `sheet_params` writes part numbers back onto the sheets.+use crate::support::{self, s, timeout_of};+use crate::agent;+use serde_json::{json, Map, Value};+use std::collections::{BTreeMap, HashMap};+use std::path::{Path, PathBuf};+use std::time::Duration;++/// Parameter names that mean the same thing across libraries: Altium's own templates, the Adom+/// part import (`MPN`, `Mouser Part #`...), the wiki's Cloud Library export (`Manufacturer Part+/// Number`), Altium 365 supplier links (`Supplier Part Number 1`) and Fusion-style attributes.+const MPN: &[&str] = &["MPN", "Manufacturer Part Number", "Manufacturer Part Number 1", "Mfr Part #", "Mfr. Part Number", "MFR PN", "MFR_PN", "MANUFACTURER_PART_NUMBER", "Part Number", "PartNumber", "Part_Number"];+const MFR: &[&str] = &["Manufacturer", "Manufacturer 1", "Manufacturer Name", "Mfr", "MANUFACTURER"];+const MOUSER: &[&str] = &["Mouser Part #", "Mouser Part Number", "Mouser PN", "MOUSER_PN"];+const DIGIKEY: &[&str] = &["DigiKey Part #", "Digi-Key Part Number", "DigiKey Part Number", "Digikey PN", "DIGIKEY_PN"];+const LCSC: &[&str] = &["LCSC Part #", "LCSC", "LCSC Part", "LCSC_PN", "JLCPCB Part #", "JLC Part #"];+const VALUE: &[&str] = &["Value", "VALUE"];+const PACKAGE: &[&str] = &["Package", "Case/Package", "PACKAGE"];+const DNP: &[&str] = &["DNP", "Do Not Populate", "DNF", "Do Not Fit"];+const DESCRIPTION: &[&str] = &["Description", "DESCRIPTION"];++/// ISch_Component.ComponentKind values that stay out of a BOM: Graphical, Net Tie (no BOM),+/// Standard (no BOM). Mechanical (1), Net Tie (in BOM, 3) and Jumper (6) are bought parts.+const NO_BOM_KINDS: &[i64] = &[2, 4, 5];++fn param(params: &[(String, String)], names: &[&str]) -> String {+    for n in names {+        if let Some((_, v)) = params.iter().find(|(k, v)| k.eq_ignore_ascii_case(n) && !v.trim().is_empty() && !v.trim().starts_with('=')) {+            return v.trim().to_string();+        }+    }+    String::new()+}++fn truthy(v: &str) -> bool {+    matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "y" | "dnp" | "x")+}++/// R1 < R2 < R10 < R10A: the letters, then the number, then the rest.+pub fn natural_key(d: &str) -> (String, u64, String) {+    let pre: String = d.chars().take_while(|c| !c.is_ascii_digit()).collect();+    let rest = &d[pre.len()..];+    let num: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();+    (pre.to_ascii_uppercase(), num.parse().unwrap_or(0), rest[num.len()..].to_string())+}++/// The project file: its schematic sheets and board (absolute), and its variants.+#[derive(Debug, Default)]+pub struct Project {+    pub sheets: Vec<String>,+    pub boards: Vec<String>,+    /// variant description -> designator -> (kind, alternate part)+    pub variants: Vec<(String, HashMap<String, (i64, String)>)>,+}++pub fn parse_project(text: &str, dir: &Path) -> Project {+    let mut p = Project::default();+    let mut section = String::new();+    let mut current: Option<(String, HashMap<String, (i64, String)>)> = None;+    for raw in text.lines() {+        let line = raw.trim();+        if line.starts_with('[') && line.ends_with(']') {+            if let Some(v) = current.take() { p.variants.push(v); }+            section = line[1..line.len() - 1].to_string();+            if section.starts_with("ProjectVariant") { current = Some((String::new(), HashMap::new())); }+            continue;+        }+        let Some((k, v)) = line.split_once('=') else { continue };+        // [DocumentN], and [DeviceSheetN]: a managed device sheet the project pulled from its workspace+        if (section.starts_with("Document") || section.starts_with("DeviceSheet")) && k == "DocumentPath" {+            let path = if Path::new(v).is_absolute() || v.contains(':') { PathBuf::from(v) } else { dir.join(v.replace('\\', "/")) };+            let full = path.to_string_lossy().replace('/', "\\");+            let low = v.to_ascii_lowercase();+            if low.ends_with(".schdoc") { p.sheets.push(full); } else if low.ends_with(".pcbdoc") { p.boards.push(full); }+        } else if let Some((_, m)) = current.as_mut().filter(|_| k.starts_with("Variation") && k[9..].chars().all(|c| c.is_ascii_digit()) && k.len() > 9) {+            // Variation1=Designator=R1|UniqueId=\ABCD|Kind=1|AlternatePart=+            let f: HashMap<&str, &str> = v.split('|').filter_map(|x| x.split_once('=')).collect();+            if let Some(d) = f.get("Designator") {+                m.insert(d.to_ascii_uppercase(), (f.get("Kind").and_then(|x| x.parse().ok()).unwrap_or(0), f.get("AlternatePart").unwrap_or(&"").to_string()));+            }+        } else if let Some((name, _)) = current.as_mut().filter(|_| k == "Description") {+            *name = v.to_string();+        }+    }+    if let Some(v) = current.take() { p.variants.push(v); }+    p+}++/// One placed part, merged from its sheet (parameters) and the board (footprint, placement).+#[derive(Debug, Clone, Default)]+pub struct Part {+    pub designator: String,+    pub comment: String,+    pub description: String,+    pub lib_ref: String,+    pub sheet: String,+    pub kind: i64,+    pub params: Vec<(String, String)>,+    pub footprint: String,+    pub placed: Option<(f64, f64, f64, String)>,+    pub dnp: bool,+    pub alternate: String,+    /// a workspace-managed part (Altium 365 / a content vault): its item GUID and source library+    pub item_guid: String,+    pub source_lib: String,+}++impl Part {+    fn get(&self, names: &[&str]) -> String { param(&self.params, names) }+    pub fn mpn(&self) -> String { self.get(MPN) }+    fn value(&self) -> String { let v = self.get(VALUE); if v.is_empty() { self.comment.clone() } else { v } }+}++const FIELDS: &[(&str, &[&str])] = &[("mpn", MPN), ("manufacturer", MFR), ("mouser", MOUSER), ("digikey", DIGIKEY), ("lcsc", LCSC)];++/// Group the fitted parts: one line per manufacturer part number, else per comment + footprint.+/// Returns (lines, checks).+pub fn group(parts: &[Part]) -> (Vec<Value>, Value) {+    let mut groups: BTreeMap<String, Vec<&Part>> = BTreeMap::new();+    for p in parts.iter().filter(|p| !p.dnp && !NO_BOM_KINDS.contains(&p.kind)) {+        let m = p.mpn();+        let key = if m.is_empty() { format!("~{}|{}", p.value().to_ascii_uppercase(), p.footprint.to_ascii_uppercase()) } else { format!("{}|{}", m.to_ascii_uppercase(), p.get(MFR).to_ascii_uppercase()) };+        groups.entry(key).or_default().push(p);+    }+    let mut lines = Vec::new();+    let mut inconsistent = Vec::new();+    for (_, mut g) in groups {+        g.sort_by_key(|p| natural_key(&p.designator));+        let refs: Vec<String> = g.iter().map(|p| p.designator.clone()).collect();+        let mut line = Map::new();+        line.insert("quantity".into(), json!(g.len()));+        line.insert("designators".into(), json!(refs));+        for (name, names) in FIELDS {+            let vals: Vec<String> = g.iter().map(|p| p.get(names)).collect();+            let mut distinct: Vec<&String> = vals.iter().filter(|v| !v.is_empty()).collect();+            distinct.sort(); distinct.dedup();+            if distinct.len() > 1 { inconsistent.push(json!({"field": name, "designators": refs, "values": distinct})); }+            line.insert((*name).into(), json!(distinct.first().map(|s| s.as_str()).unwrap_or("")));+        }+        let first = g[0];+        let mut fps: Vec<&str> = g.iter().map(|p| p.footprint.as_str()).filter(|f| !f.is_empty()).collect();+        fps.sort(); fps.dedup();+        if fps.len() > 1 { inconsistent.push(json!({"field": "footprint", "designators": refs, "values": fps})); }+        line.insert("value".into(), json!(first.value()));+        line.insert("comment".into(), json!(first.comment));+        line.insert("description".into(), json!(if first.description.is_empty() { first.get(DESCRIPTION) } else { first.description.clone() }));+        line.insert("sourceLibrary".into(), json!(first.source_lib));+        line.insert("managed".into(), json!(!first.item_guid.is_empty()));+        line.insert("footprint".into(), json!(fps.first().copied().unwrap_or("")));+        line.insert("package".into(), json!(first.get(PACKAGE)));+        line.insert("libReference".into(), json!(first.lib_ref));+        lines.push(Value::Object(line));+    }+    lines.sort_by_key(|l| natural_key(l["designators"][0].as_str().unwrap_or("")));+    let fitted: Vec<&Part> = parts.iter().filter(|p| !p.dnp && !NO_BOM_KINDS.contains(&p.kind)).collect();+    let missing = |names: &[&str]| -> Vec<String> {+        let mut v: Vec<String> = fitted.iter().filter(|p| p.get(names).is_empty()).map(|p| p.designator.clone()).collect();+        v.sort_by_key(|d| natural_key(d)); v+    };+    let mut dnp: Vec<String> = parts.iter().filter(|p| p.dnp).map(|p| p.designator.clone()).collect();+    dnp.sort_by_key(|d| natural_key(d));+    let mut excluded: Vec<String> = parts.iter().filter(|p| !p.dnp && NO_BOM_KINDS.contains(&p.kind)).map(|p| p.designator.clone()).collect();+    excluded.sort_by_key(|d| natural_key(d));+    let checks = json!({+        "missingMpn": missing(MPN), "missingManufacturer": missing(MFR),+        "missingPartNumbers": {"mouser": missing(MOUSER), "digikey": missing(DIGIKEY), "lcsc": missing(LCSC)},+        "inconsistent": inconsistent, "dnp": dnp, "excludedKinds": excluded,+    });+    (lines, checks)+}++fn csv_cell(s: &str) -> String {+    if s.contains([',', '"', '\n', '\r']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }+}++fn csv(rows: &[Vec<String>]) -> String {+    rows.iter().map(|r| r.iter().map(|c| csv_cell(c)).collect::<Vec<_>>().join(",")).collect::<Vec<_>>().join("\r\n") + "\r\n"+}++fn st(v: &Value) -> String { v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()) }++/// The files, as (name, text). `boards` multiplies the quantities of the ordering files.+pub fn files(lines: &[Value], parts: &[Part], boards: u64) -> Vec<(String, String)> {+    let refs = |l: &Value| l["designators"].as_array().map(|a| a.iter().map(st).collect::<Vec<_>>().join(",")).unwrap_or_default();+    let mut out = Vec::new();+    let mut rows = vec![["Line", "Quantity", "Designators", "Value", "Comment", "Description", "Footprint", "Package", "MPN", "Manufacturer", "Mouser Part #", "DigiKey Part #", "LCSC Part #"].map(String::from).to_vec()];+    for (i, l) in lines.iter().enumerate() {+        rows.push(vec![(i + 1).to_string(), st(&l["quantity"]), refs(l), st(&l["value"]), st(&l["comment"]), st(&l["description"]), st(&l["footprint"]), st(&l["package"]), st(&l["mpn"]), st(&l["manufacturer"]), st(&l["mouser"]), st(&l["digikey"]), st(&l["lcsc"])]);+    }+    out.push(("bom.csv".to_string(), csv(&rows)));+    // JLCPCB assembly: their BOM and CPL column names, mm, Top/Bottom+    let mut j = vec![["Comment", "Designator", "Footprint", "LCSC Part #"].map(String::from).to_vec()];+    for l in lines { j.push(vec![st(&l["value"]), refs(l), st(&l["footprint"]), st(&l["lcsc"])]); }+    out.push(("bom-jlcpcb.csv".to_string(), csv(&j)));+    let mut c = vec![["Designator", "Mid X", "Mid Y", "Layer", "Rotation"].map(String::from).to_vec()];+    let mut placed: Vec<&Part> = parts.iter().filter(|p| !p.dnp && !NO_BOM_KINDS.contains(&p.kind) && p.placed.is_some()).collect();+    placed.sort_by_key(|p| natural_key(&p.designator));+    for p in placed {+        let (x, y, r, side) = p.placed.clone().unwrap();+        c.push(vec![p.designator.clone(), format!("{x:.4}mm"), format!("{y:.4}mm"), if side == "bottom" { "Bottom".into() } else { "Top".into() }, format!("{}", (r.rem_euclid(360.0) * 1000.0).round() / 1000.0)]);+    }+    out.push(("cpl-jlcpcb.csv".to_string(), csv(&c)));+    // distributor BOM tools (Mouser BOM Tool, DigiKey myLists / BOM Manager) map these headers directly+    for (name, field, header) in [("bom-mouser.csv", "mouser", "Mouser Part Number"), ("bom-digikey.csv", "digikey", "Digi-Key Part Number")] {+        let mut m = vec![vec![header.to_string(), "Manufacturer Part Number".into(), "Manufacturer".into(), "Quantity".into(), "Customer Reference".into()]];+        for l in lines.iter().filter(|l| !st(&l[field]).is_empty() || !st(&l["mpn"]).is_empty()) {+            m.push(vec![st(&l[field]), st(&l["mpn"]), st(&l["manufacturer"]), (l["quantity"].as_u64().unwrap_or(0) * boards.max(1)).to_string(), refs(l)]);+        }+        out.push((name.to_string(), csv(&m)));+    }+    out+}++fn sheet_parts(sheet: &str, timeout: Duration) -> Result<(String, Vec<Value>), Value> {+    if let Some(e) = support::must_open(sheet, 60) { return Err(e); }+    // only the values a BOM reads: other parameters can hold anything (an example IC's preview-image blobs+    // took minutes to read), so their values are never fetched+    let mut names: Vec<&str> = [MPN, MFR, MOUSER, DIGIKEY, LCSC, VALUE, PACKAGE, DNP, DESCRIPTION, &["Fitted"]].concat();+    names.sort(); names.dedup();+    let filter = format!("|{}|", names.iter().map(|n| n.to_ascii_uppercase()).collect::<Vec<_>>().join("|"));+    let r = agent::call("schematic_data", &[("pins", "0".into()), ("params", "1".into()), ("limit", "5000".into()), ("paramFilter", filter)], None, timeout);+    match r.get("data") {+        Some(d) if d["ok"].as_bool() == Some(true) => Ok((st(&d["document"]), d["components"].as_array().cloned().unwrap_or_default())),+        _ => Err(r),+    }+}++/// altium_bom {projectPath, variant?, outputDir?, boards?, write?}+pub fn bom(args: &Value) -> Value {+    if let Some(g) = support::need_licensed() { return g; }+    let t0 = support::now();+    let Some(prj) = s(args, "projectPath") else {+        return json!({"success": false, "errorCode": "bad_args", "error": "projectPath (.PrjPcb) is required", "_hint": "Pass the project file; the BOM reads every schematic sheet it lists and its board."});+    };+    let text = match std::fs::read(&prj) { Ok(b) => String::from_utf8_lossy(&b).to_string(), Err(e) => return json!({"success": false, "errorCode": "project_not_found", "error": format!("{prj}: {e}")}) };+    let dir = Path::new(&prj).parent().map(Path::to_path_buf).unwrap_or_default();+    let project = parse_project(&text, &dir);+    if project.sheets.is_empty() { return json!({"success": false, "errorCode": "no_sheets", "error": "the project lists no .SchDoc", "projectPath": prj}); }+    let timeout = timeout_of(args, 120);+    // the variant: by its description, or none+    let want_variant = s(args, "variant");+    let variant = match &want_variant {+        Some(v) => match project.variants.iter().find(|(n, _)| n.eq_ignore_ascii_case(v)) {+            Some(x) => Some(x),+            None => return json!({"success": false, "errorCode": "variant_not_found", "error": format!("no variant named {v}"), "variants": project.variants.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>()}),+        },+        None => None,+    };+    let mut parts: BTreeMap<String, Part> = BTreeMap::new();+    let mut sheets_read = Vec::new();+    // altium_progress shows which sheet a long read is on (a large project reads dozens)+    crate::progress::begin("bom", "warm", "reading the schematic sheets", "bridge");+    let n_sheets = project.sheets.len();+    for (i, sheet) in project.sheets.iter().enumerate() {+        let name = Path::new(&sheet.replace('\\', "/")).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default();+        crate::progress::step("bom", &format!("sheet {} of {n_sheets}: {name}", i + 1), Some((i * 90 / n_sheets.max(1)) as i64), None, "bridge");+        let (doc, comps) = match sheet_parts(sheet, timeout) { Ok(x) => x, Err(e) => { crate::progress::end("bom", "bridge"); return json!({"success": false, "errorCode": "sheet_read_failed", "sheet": sheet, "reply": e}) } };+        sheets_read.push(json!({"sheet": doc, "components": comps.len()}));+        for c in comps {+            let d = st(&c["designator"]);+            if d.is_empty() || d.ends_with('?') { continue; }+            let params: Vec<(String, String)> = c["params"].as_array().cloned().unwrap_or_default().iter().filter(|p| p.get("value").is_some()).map(|p| (st(&p["name"]), st(&p["value"]))).collect();+            // a multi-part component appears once per part: keep the first, add any parameter it lacks+            let e = parts.entry(d.to_ascii_uppercase()).or_insert_with(|| Part {+                designator: d.clone(), comment: st(&c["comment"]), description: st(&c["description"]), lib_ref: st(&c["libReference"]),+                sheet: doc.clone(), kind: c["kind"].as_i64().unwrap_or(0), item_guid: st(&c["itemGuid"]), source_lib: st(&c["sourceLibrary"]), ..Default::default() });+            for (k, v) in params { if !e.params.iter().any(|(k2, _)| k2.eq_ignore_ascii_case(&k)) { e.params.push((k, v)); } }+        }+    }+    // the board: footprint and placement of each part+    let mut board_only = Vec::new();+    let board = s(args, "pcbPath").or_else(|| project.boards.first().cloned());+    let mut board_read = Value::Null;+    if let Some(b) = &board {+        crate::progress::step("bom", "reading the board's placement", Some(92), None, "bridge");+        if let Some(e) = support::must_open(b, 60) { crate::progress::end("bom", "bridge"); return json!({"success": false, "errorCode": "board_open_failed", "board": b, "reply": e}); }+        // the lean placement read: a full routing_state of a large board (MiniPC) ran past 10 minutes+        let r = agent::call("bom_placement", &[], None, timeout);+        let Some(comps) = r.get("data").filter(|d| d["ok"].as_bool() == Some(true)).and_then(|d| d["comps"].as_array()).cloned() else {+            crate::progress::end("bom", "bridge");+            return json!({"success": false, "errorCode": "board_read_failed", "board": b, "reply": r});+        };+        board_read = json!({"board": b, "components": comps.len()});+        for c in comps {+            // [designator, pattern, x, y (raw from the origin, 10000 per mil), rotation (millidegrees), bottom]+            let d = st(&c[0]);+            let mm = |v: &Value| (v.as_f64().unwrap_or(0.0) / 10000.0 * 0.0254 * 10000.0).round() / 10000.0;+            let placed = Some((mm(&c[2]), mm(&c[3]), c[4].as_f64().unwrap_or(0.0) / 1000.0, if c[5].as_bool() == Some(true) { "bottom".to_string() } else { "top".to_string() }));+            match parts.get_mut(&d.to_ascii_uppercase()) {+                Some(p) => { p.footprint = st(&c[1]); p.placed = placed; }+                None => board_only.push(d),+            }+        }+    }+    let mut not_on_board: Vec<String> = if board.is_some() { parts.values().filter(|p| p.placed.is_none() && !NO_BOM_KINDS.contains(&p.kind)).map(|p| p.designator.clone()).collect() } else { vec![] };+    not_on_board.sort_by_key(|d| natural_key(d));+    board_only.sort_by_key(|d| natural_key(d));+    for p in parts.values_mut() {+        if truthy(&p.get(DNP)) { p.dnp = true; }+        if p.get(&["Fitted"]).eq_ignore_ascii_case("false") { p.dnp = true; }+        if let Some((_, m)) = variant {+            if let Some((kind, alt)) = m.get(&p.designator.to_ascii_uppercase()) {+                if *kind == 1 { p.dnp = true; } else if !alt.is_empty() { p.alternate = alt.clone(); }+            }+        }+    }+    crate::progress::end("bom", "bridge");+    let list: Vec<Part> = parts.values().cloned().collect();+    let (lines, mut checks) = group(&list);+    checks["sheetNotOnBoard"] = json!(not_on_board);+    checks["boardNotOnSheet"] = json!(board_only);+    let alternates: Vec<Value> = list.iter().filter(|p| !p.alternate.is_empty()).map(|p| json!({"designator": p.designator, "alternatePart": p.alternate})).collect();+    let fitted = list.iter().filter(|p| !p.dnp && !NO_BOM_KINDS.contains(&p.kind)).count();+    let boards = args.get("boards").and_then(|v| v.as_u64()).unwrap_or(1);+    let mut written = Vec::new();+    if args.get("write").and_then(|b| b.as_bool()) != Some(false) {+        let out_dir = s(args, "outputDir").map(PathBuf::from).unwrap_or_else(|| dir.join("BOM"));+        if let Err(e) = std::fs::create_dir_all(&out_dir) { return json!({"success": false, "errorCode": "output_dir", "error": format!("{}: {e}", out_dir.display())}); }+        for (name, body) in files(&lines, &list, boards) {+            let p = out_dir.join(&name);+            if let Err(e) = std::fs::write(&p, body) { return json!({"success": false, "errorCode": "write_failed", "error": format!("{}: {e}", p.display())}); }+            written.push(p.to_string_lossy().to_string());+        }+        let j = out_dir.join("bom.json");+        let _ = std::fs::write(&j, serde_json::to_string_pretty(&json!({"project": prj, "variant": want_variant, "lines": lines, "checks": checks})).unwrap_or_default());+        written.push(j.to_string_lossy().to_string());+    }+    let missing = checks["missingMpn"].as_array().map(|a| a.len()).unwrap_or(0);+    let gaps: Vec<String> = ["mouser", "digikey", "lcsc"].iter().filter_map(|k| { let n = checks["missingPartNumbers"][k].as_array().map(|a| a.len()).unwrap_or(0); (n > 0).then(|| format!("{n} without a {k} number")) }).collect();+    let mut hint = format!("{} lines, {fitted} fitted parts{}. ", lines.len(), if checks["dnp"].as_array().map_or(true, |a| a.is_empty()) { String::new() } else { format!(", {} DNP", checks["dnp"].as_array().unwrap().len()) });+    if missing > 0 { hint.push_str(&format!("{missing} part(s) have no MPN: find them (adom-parts-search search <value> <package>) and write them back with altium_sheet_params. ")); }+    if !gaps.is_empty() { hint.push_str(&format!("Distributor numbers: {}; look them up by MPN (adom-parts-search search <MPN>) and write them back with altium_sheet_params. ", gaps.join(", "))); }+    if checks["inconsistent"].as_array().map_or(false, |a| !a.is_empty()) { hint.push_str("Some lines mix values for one part number (checks.inconsistent): fix the sheets so every part of a line agrees. "); }+    hint.push_str("Price and stock the lines with adom-parts-search / adom-mouser part <MPN>; bom-mouser.csv and bom-digikey.csv upload to those sites' BOM tools; bom-jlcpcb.csv + cpl-jlcpcb.csv go to JLCPCB assembly (check rotations on their preview).");+    json!({"success": true, "projectPath": prj, "variant": want_variant, "variants": project.variants.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>(),+        "sheets": sheets_read, "board": board_read, "lines": lines, "lineCount": lines.len(), "fittedParts": fitted, "checks": checks,+        "alternates": alternates, "boards": boards, "files": written, "elapsedSec": support::elapsed_round(t0), "windowEtiquette": "background", "_hint": hint})+}++/// altium_sheet_params {sheet?, designators, params, save?}: write parameters onto placed parts.+/// Without a sheet, every sheet of projectPath is tried and each designator lands where it lives.+pub fn sheet_params(args: &Value) -> Value {+    if let Some(g) = support::need_licensed() { return g; }+    let designators: Vec<String> = match args.get("designators") {+        Some(Value::Array(a)) => a.iter().map(st).collect(),+        Some(Value::String(t)) => t.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(),+        _ => vec![],+    };+    let params: Vec<String> = match args.get("params") {+        Some(Value::Object(o)) => o.iter().map(|(k, v)| format!("{}={}", k.trim(), st(v).replace(';', ","))).collect(),+        Some(Value::String(t)) => t.split(';').map(str::to_string).filter(|x| x.contains('=')).collect(),+        _ => vec![],+    };+    if designators.is_empty() || params.is_empty() {+        return json!({"success": false, "errorCode": "bad_args", "error": "designators and params are required", "_hint": "designators: [\"R1\",\"R2\"]; params: {\"MPN\": \"...\", \"Manufacturer\": \"...\", \"Mouser Part #\": \"...\"}"});+    }+    if params.iter().any(|p| p.contains('\n') || p.contains('\r')) { return json!({"success": false, "errorCode": "bad_args", "error": "parameter values may not contain line breaks"}); }+    let sheets: Vec<String> = match (s(args, "sheet"), s(args, "projectPath")) {+        (Some(sh), _) => vec![sh],+        (None, Some(prj)) => {+            let text = std::fs::read(&prj).map(|b| String::from_utf8_lossy(&b).to_string()).unwrap_or_default();+            parse_project(&text, Path::new(&prj).parent().unwrap_or(Path::new("."))).sheets+        }+        _ => return json!({"success": false, "errorCode": "bad_args", "error": "sheet (.SchDoc) or projectPath (.PrjPcb) is required"}),+    };+    let mut done = Vec::new();+    let mut left: Vec<String> = designators.clone();+    let mut per_sheet = Vec::new();+    for sheet in &sheets {+        if left.is_empty() { break; }+        if let Some(e) = support::must_open(sheet, 60) { return json!({"success": false, "errorCode": "sheet_open_failed", "sheet": sheet, "reply": e}); }+        let r = agent::call("sheet_params", &[("designators", left.join(",")), ("params", params.join(";"))], None, timeout_of(args, 90));+        let d = r.get("data").cloned().unwrap_or(Value::Null);+        if d["ok"].as_bool() != Some(true) { return json!({"success": false, "errorCode": d["errorCode"].as_str().unwrap_or("sheet_params_failed"), "error": d["error"], "sheet": sheet, "reply": r}); }+        let here: Vec<String> = d["designators"].as_array().cloned().unwrap_or_default().iter().map(st).collect();+        if !here.is_empty() {+            if args.get("save").and_then(|b| b.as_bool()) != Some(false) {+                let sv = crate::surfaces::save(&json!({"filePath": sheet}));+                if sv["success"].as_bool() == Some(false) { return json!({"success": false, "errorCode": "save_failed", "sheet": sheet, "reply": sv}); }+            }+            per_sheet.push(json!({"sheet": sheet, "designators": here, "paramsSet": d["paramsSet"]}));+            left.retain(|x| !here.iter().any(|h| h.eq_ignore_ascii_case(x)));+            done.extend(here);+        }+    }+    json!({"success": left.is_empty(), "errorCode": if left.is_empty() { Value::Null } else { json!("designator_not_found") }, "updated": done, "notFound": left, "sheets": per_sheet,+        "_hint": if left.is_empty() { "Written on the sheets (saved unless save:false). The board's components pick the parameters up with Design > Update PCB; the BOM reads the sheets, so altium_bom shows them now." } else { "Some designators are on no sheet of the project: check the names (altium_bom lists every designator)." }})+}++#[cfg(test)]+mod tests {+    use super::*;++    const PRJ: &str = "[Design]\r\nVersion=1.0\r\n\r\n[Document1]\r\nDocumentPath=Power.SchDoc\r\n\r\n[Document2]\r\nDocumentPath=Sub\\IO.SchDoc\r\n\r\n[Document3]\r\nDocumentPath=Board.PcbDoc\r\n\r\n[DeviceSheet1]\r\nDocumentPath=Managed\\Sheets\\AB\\Osc.SchDoc\r\n\r\n[DeviceSheet2]\r\nDocumentPath=Managed\\Sheets\\AB\\Osc.Harness\r\n\r\n[ProjectVariant1]\r\nDescription=Lite\r\nVariationCount=2\r\nVariation1=Designator=R2|UniqueId=\\ABC|Kind=1|AlternatePart=\r\nVariation2=Designator=U1|UniqueId=\\DEF|Kind=2|AlternatePart=TPS54302\r\n";++    fn part(d: &str, params: &[(&str, &str)], fp: &str) -> Part {+        Part { designator: d.into(), comment: "c".into(), footprint: fp.into(), params: params.iter().map(|(a, b)| (a.to_string(), b.to_string())).collect(), placed: Some((1.0, 2.0, 90.0, "top".into())), ..Default::default() }+    }++    #[test]+    fn the_project_file_gives_sheets_board_and_variants() {+        let p = parse_project(PRJ, Path::new("C:/proj"));+        assert_eq!(p.sheets.len(), 3, "two sheets and a device sheet (its harness is not a sheet)");+        assert!(p.sheets[1].ends_with("Sub\\IO.SchDoc"), "{:?}", p.sheets);+        assert!(p.sheets[2].ends_with("Managed\\Sheets\\AB\\Osc.SchDoc"), "{:?}", p.sheets);+        assert_eq!(p.boards.len(), 1);+        assert_eq!(p.variants.len(), 1);+        assert_eq!(p.variants[0].0, "Lite");+        assert_eq!(p.variants[0].1["R2"].0, 1);+        assert_eq!(p.variants[0].1["U1"], (2, "TPS54302".to_string()));+    }++    #[test]+    fn lines_group_by_mpn_across_parameter_names() {+        let parts = vec![+            part("R10", &[("MPN", "RC0402-10K"), ("Manufacturer", "Yageo"), ("Mouser Part #", "603-RC0402")], "R0402"),+            part("R2", &[("Manufacturer Part Number", "RC0402-10K"), ("Manufacturer", "Yageo")], "R0402"),+            part("C1", &[("Value", "100n")], "C0402"),+            part("C2", &[("Value", "100n")], "C0402"),+            part("TP1", &[("DNP", "yes")], "TP"),+        ];+        let mut parts = parts;+        parts[4].dnp = true;+        let (lines, checks) = group(&parts);+        assert_eq!(lines.len(), 2);+        assert_eq!(lines[0]["designators"], json!(["C1", "C2"]));+        assert_eq!(lines[1]["designators"], json!(["R2", "R10"]), "natural order");+        assert_eq!(lines[1]["mpn"], json!("RC0402-10K"));+        assert_eq!(lines[1]["mouser"], json!("603-RC0402"));+        assert_eq!(checks["missingMpn"], json!(["C1", "C2"]));+        assert_eq!(checks["missingPartNumbers"]["mouser"], json!(["C1", "C2", "R2"]));+        assert_eq!(checks["dnp"], json!(["TP1"]));+        assert!(checks["inconsistent"].as_array().unwrap().is_empty());+    }++    #[test]+    fn a_line_with_two_footprints_is_inconsistent() {+        let parts = vec![part("R1", &[("MPN", "X")], "R0402"), part("R2", &[("MPN", "X")], "R0603")];+        let (_, checks) = group(&parts);+        assert_eq!(checks["inconsistent"][0]["field"], json!("footprint"));+    }++    #[test]+    fn no_bom_kinds_stay_out() {+        let mut logo = part("LOGO1", &[], "");+        logo.kind = 2;+        let (lines, checks) = group(&[part("R1", &[("MPN", "X")], "R0402"), logo]);+        assert_eq!(lines.len(), 1);+        assert_eq!(checks["excludedKinds"], json!(["LOGO1"]));+    }++    #[test]+    fn files_carry_the_columns_each_site_takes() {+        let parts = vec![part("U1", &[("MPN", "TPS54202DDCR"), ("Manufacturer", "TI"), ("LCSC Part #", "C191884"), ("Mouser Part #", "595-TPS54202DDCR")], "SOT-23-6")];+        let (lines, _) = group(&parts);+        let f: HashMap<String, String> = files(&lines, &parts, 5).into_iter().collect();+        assert!(f["bom-jlcpcb.csv"].starts_with("Comment,Designator,Footprint,LCSC Part #\r\n"));+        assert!(f["bom-jlcpcb.csv"].contains("U1,SOT-23-6,C191884"));+        assert!(f["cpl-jlcpcb.csv"].contains("U1,1.0000mm,2.0000mm,Top,90"));+        assert!(f["bom-mouser.csv"].contains("595-TPS54202DDCR,TPS54202DDCR,TI,5,U1"), "{}", f["bom-mouser.csv"]);+        assert!(f["bom.csv"].lines().nth(1).unwrap().starts_with("1,1,U1,"));+    }++    #[test]+    fn csv_quotes_what_needs_it() {+        assert_eq!(csv_cell("R1,R2"), "\"R1,R2\"");+        assert_eq!(csv_cell("10\" cable"), "\"10\"\" cable\"");+        assert_eq!(csv_cell("plain"), "plain");+    }+}
bridge-rs/src/main.rs+2−1
@@ -5,6 +5,7 @@ mod a365; mod ab; mod agent; mod board;+mod bom; mod dialogs; mod health; mod install;@@ -31,7 +32,7 @@ use tiny_http::{Header, Method, Response, Server}; /// Attached to every failed reply (see the /command handler). const REPORT_ISSUES: &str = "If this looks like an altium-bridge BUG (not a refusal you expected), report it: check for a duplicate with `adom-wiki issue list adom/altium-bridge`, then `adom-wiki issue create adom/altium-bridge --title \"<verb>: <one-line symptom>\" --category bug-report --body \"<what you ran, what you expected, what happened, the VERBATIM response JSON (and errorShot if present), bridgeVersion, the Altium version from altium_status, the OS>\"`. A relay, routing, toast or lifecycle problem belongs on adom/adom-bridge instead. Reports from real machines are how this bridge gets correct: three different Altium builds behaved three different ways in one weekend."; -pub const BRIDGE_VERSION: &str = "0.2.55";+pub const BRIDGE_VERSION: &str = "0.2.56";  fn json_response(status: u16, body: &serde_json::Value) -> Response<std::io::Cursor<Vec<u8>>> {     let data = serde_json::to_vec(body).unwrap_or_else(|_| b"{}".to_vec());
bridge-rs/src/verbs.rs+21−4
@@ -28,7 +28,7 @@ pub const KNOWN: &[&str] = &[     "show_symbol", "show_footprint", "show_3d_chip", "list_library", "demo",     "animate_view", "foreground",     // outputs / project-    "compile", "run_erc", "run_drc", "run_outjob", "export_gerber", "export_bom", "export_step",+    "compile", "run_erc", "run_drc", "run_outjob", "export_gerber", "export_bom", "export_step", "bom", "sheet_params",     "save", "test_board", "board_shape", "pcb_report", "import_kicad",     "install_extension", "extension_ping",     // library authoring@@ -498,8 +498,8 @@ fn describe() -> Value {             "Use it to verify an imported footprint on a altium_test_board: pad layerId 74 = multi-layer (plated through), which is what altium_plate_vias produces.",             &["component_pins", "plate_vias", "test_board", "pcb_report"], &["Needs a .PcbDoc current: it reads a placed component, not a PcbLib member (altium_pcb_report reads libraries).", "3D bodies, fills and regions only show up in otherPrimitives."], json!({"designator": "U1"})),         verb_doc("schematic_data", "Components on a schematic sheet (filePath, else the CURRENT sheet) with their pins (number, name, electrical type), position and orientation. designator filters to one part.",-            json!({"filePath": "path to the .SchDoc - optional, made current first (alias sheet)", "designator": "string - optional filter", "limit": "int, default 500", "pins": "bool, default true", "params": "bool, default false - include each component's parameters"}),-            json!({"data": "{document, count, components: [{designator, partId, libReference, comment, xMil, yMil, orientation, pins: [{number, name, electrical (0 Input, 1 IO, 2 Output, 3 OpenCollector, 4 Passive, 5 HiZ, 6 OpenEmitter, 7 Power), xMil, yMil, orientation, lengthMil}], params: []}]}", "roundtripMs": "int"}), 120,+            json!({"filePath": "path to the .SchDoc - optional, made current first (alias sheet)", "designator": "string - optional filter", "limit": "int, default 500", "pins": "bool, default true", "params": "bool, default false - include each component's parameters", "paramNamesOnly": "bool, default false - parameter names without values (a library with blob parameters reads fast)"}),+            json!({"data": "{document, count, components: [{designator, partId, libReference, comment, description (empty for a workspace-managed part), kind (ComponentKind: 0 Standard, 1 Mechanical, 2 Graphical, 3 Net Tie BOM, 4 Net Tie, 5 Standard no BOM, 6 Jumper), itemGuid, vaultGuid, sourceLibrary, xMil, yMil, orientation, pins: [{number, name, electrical (0 Input, 1 IO, 2 Output, 3 OpenCollector, 4 Passive, 5 HiZ, 6 OpenEmitter, 7 Power), xMil, yMil, orientation, lengthMil}], params: []}]}", "roundtripMs": "int"}), 120,             "Pin-level view of a sheet. params[] is empty unless you pass params:true; altium_read_schematic is the fuller parameter view.",             &["read_schematic", "cross_check", "show_schematic"], &["One sheet per call. Without filePath the current document must be a .SchDoc: a schematic library is refused (no_current_schematic), not read as an empty sheet.", "electrical is Altium's pin-type ordinal, not a name."], json!({"designator": "U1"})),         // ---- surfaces ----@@ -624,6 +624,20 @@ fn describe() -> Value {             &["run_outjob", "export_bom", "export_step", "run_drc"],             &["There is NO board-path argument: without outjobPath it exports Altium's EXAMPLE design. The user's project needs its own OutJob.", "OutJobs open what they generate: expect CAM documents left open in the user's session."],             json!({"outjobPath": "C:/proj/Fabrication.OutJob", "projectPath": "C:/proj/board.PrjPcb"})),+        verb_doc("bom", "The project's bill of materials read straight from Altium: every schematic sheet the .PrjPcb lists (placed parts and their parameters) merged with the board (footprint, position, side), a variant applied, grouped one line per manufacturer part number, checked for what an order needs, and written as bom.csv, bom-jlcpcb.csv + cpl-jlcpcb.csv, bom-mouser.csv and bom-digikey.csv (+ bom.json).",+            json!({"projectPath": "path to the .PrjPcb (required)", "pcbPath": "path - optional, default the project's board", "variant": "string - optional, a project variant's description (Not Fitted parts become DNP, alternates are listed)", "outputDir": "path - default <project folder>\\BOM", "boards": "int, default 1 - multiplies the quantities in the Mouser/DigiKey files", "write": "bool, default true", "timeout": "seconds per sheet read, default 120"}),+            json!({"lines": "[{quantity, designators, value, comment, description, footprint, package, mpn, manufacturer, mouser, digikey, lcsc, libReference}]", "checks": "{missingMpn, missingManufacturer, missingPartNumbers{mouser, digikey, lcsc}, inconsistent[{field, designators, values}], dnp, excludedKinds, sheetNotOnBoard, boardNotOnSheet}", "variants": "[string]", "alternates": "[{designator, alternatePart}]", "files": "[path]", "fittedParts": "int"}), 600,+            "Parameter names are matched across libraries (MPN = Manufacturer Part Number = Mfr Part #; Mouser Part # / Digi-Key Part Number; LCSC Part # / JLCPCB Part #). Missing numbers: look them up (adom-parts-search search <MPN>) and write them back with altium_sheet_params, then run this again.",+            &["sheet_params", "export_manufacturing", "read_schematic"],+            &["Graphical, Net Tie (no BOM) and Standard (no BOM) components are left out (checks.excludedKinds).", "DNP: a variant's Not Fitted parts, or a DNP / Do Not Populate / Fitted=false parameter.", "Variants are read from the .PrjPcb text; alternate parts are listed, not substituted.", "CPL rotations are Altium's: JLCPCB's preview may need per-footprint corrections.", "A multi-channel (repeated) sheet is read once: its copies' designators land in checks.boardNotOnSheet.", "Only the parameters a BOM needs are read (some libraries carry huge blob parameters); a workspace-managed part's description comes from a Description parameter, never its vault."],+            json!({"projectPath": "C:/proj/board.PrjPcb", "boards": 10})),+        verb_doc("sheet_params", "Write parameters onto PLACED parts on the schematic sheets (the BOM write-back): MPN, Manufacturer, Mouser/DigiKey/LCSC numbers, anything. A missing parameter is added hidden; one undo step per sheet; each touched sheet is saved.",+            json!({"designators": "[string] or 'R1,R2' (required; every part of a multi-part component)", "params": "{name: value} or 'Key=Value;Key=Value' (required)", "sheet": "path to a .SchDoc - or", "projectPath": "path to the .PrjPcb: each designator is found on whichever sheet holds it", "save": "bool, default true", "timeout": "seconds, default 90"}),+            json!({"updated": "[designator]", "notFound": "[designator]", "sheets": "[{sheet, designators, paramsSet}]"}), 300,+            "The board picks the new parameters up with Design > Update PCB Document; altium_bom reads the sheets, so it shows them at once.",+            &["bom", "read_schematic", "set_params"],+            &["Sheet parameters, not the library: altium_set_params changes a library part."],+            json!({"projectPath": "C:/proj/board.PrjPcb", "designators": ["R1", "R2"], "params": {"MPN": "RC0402FR-0710KL", "Manufacturer": "YAGEO", "Mouser Part #": "603-RC0402FR-0710KL"}})),         verb_doc("export_bom", "Bill of materials export: altium_run_outjob with the assembly OutJob and the 'Folder Structure' medium as defaults.",             json!({"outjobPath": "path to an .OutJob - default: the bundled example Assembly.OutJob", "medium": "string, default 'Folder Structure'", "projectPath": "path to the .PrjPcb", "timeout": "seconds, default 300"}),             json!({"surface": "bom", "outjob": "path", "outputDir": "path | null", "files": "[relative paths]", "dialogs": "[..]", "elapsedSec": "float"}), 300,@@ -1224,7 +1238,8 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) ->             // carried an empty params[] and the doc had to apologise for it. Off by default:             // a sheet's parameters are bulky and most callers want pins.             let params = if args.get("params").and_then(|b| b.as_bool()) == Some(true) { "1" } else { "0" };-            board_read("schematic_data", vec![("designator", s(args, "designator").unwrap_or_default()), ("limit", s(args, "limit").unwrap_or("500".into())), ("pins", pins.into()), ("params", params.into())], 120)+            let names_only = if args.get("paramNamesOnly").and_then(|b| b.as_bool()) == Some(true) { "1" } else { "0" };+            board_read("schematic_data", vec![("designator", s(args, "designator").unwrap_or_default()), ("limit", s(args, "limit").unwrap_or("500".into())), ("pins", pins.into()), ("params", params.into()), ("paramNamesOnly", names_only.into())], 120)         }         // ---- surfaces ----         "show_project" => surfaces::show_project(args),@@ -1275,6 +1290,8 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) ->         "run_outjob" => outputs::run_outjob(args),         "export_gerber" => outputs::export_gerber(args),         "export_bom" => outputs::export_bom(args),+        "bom" => crate::bom::bom(args),+        "sheet_params" => crate::bom::sheet_params(args),         "export_step" => outputs::export_step(args),         "test_board" => library::test_board(args),         "import_kicad" => outputs::import_kicad(args),
install.sh+4−2
@@ -20,6 +20,7 @@ for HOME_DIR in "$HOME/.claude/skills" "$HOME/.codex/skills"; do   install_skill "$HOME_DIR/altium-uia"            "$HERE/skills/altium-uia/SKILL.md"   install_skill "$HOME_DIR/altium-wiki-import"    "$HERE/skills/altium-wiki-import/SKILL.md"   install_skill "$HOME_DIR/altium-a365"           "$HERE/skills/altium-a365/SKILL.md"+  install_skill "$HOME_DIR/altium-bom"            "$HERE/skills/altium-bom/SKILL.md" done  # Dashboard: the pkg ships it, the dock runs `altium-dashboard serve --print-url`.@@ -47,7 +48,7 @@ chmod +x "$BINDIR/altium-dashboard" # and AI Flow's Altium library step run in the container. They were only in the source repo, so an AI # with just this package could not follow its own skills (found 2026-10-15). Install them as commands. mkdir -p "$APPDIR/tools"-for t in kicad2altium symbol_modes kicad_v10_to_v9; do+for t in kicad2altium symbol_modes kicad_v10_to_v9 bom_tool; do   cp -f "$HERE/tools/$t.py" "$APPDIR/tools/$t.py" done mk() {  # <command> <tool>@@ -60,4 +61,5 @@ EOF2 mk altium-kicad2altium kicad2altium mk altium-symbol-modes symbol_modes mk altium-kicad-downgrade kicad_v10_to_v9-echo "OK: altium-bridge user skills installed + altium-dashboard + altium-kicad2altium, altium-symbol-modes, altium-kicad-downgrade"+mk altium-bom bom_tool+echo "OK: altium-bridge user skills installed + altium-dashboard + altium-kicad2altium, altium-symbol-modes, altium-kicad-downgrade, altium-bom"
package.json+3−1
@@ -1,6 +1,6 @@ {   "slug": "altium-bridge",-  "version": "0.2.55",+  "version": "0.2.56",   "type": "app",   "description": "Skills for your container so your AI knows how to drive the Altium Designer bridge (altium_* verbs). The bridge runtime itself is the release zip; Adom Bridge loads that.",   "tags": [@@ -33,6 +33,7 @@     "install.sh",     "package.json",     "skills/altium-a365/SKILL.md",+    "skills/altium-bom/SKILL.md",     "skills/altium-boards/SKILL.md",     "skills/altium-bridge-plan/SKILL.md",     "skills/altium-install/SKILL.md",@@ -44,6 +45,7 @@     "skills/altium-part-import/SKILL.md",     "skills/altium-uia/SKILL.md",     "skills/altium-wiki-import/SKILL.md",+    "tools/bom_tool.py",     "tools/kicad2altium.py",     "tools/kicad_v10_to_v9.py",     "tools/symbol_modes.py",
page.json+1−1
@@ -4,7 +4,7 @@   "type": "app",   "title": "Altium - the Altium Bridge",   "brief": "Adom Bridge for Altium Designer: a resident DelphiScript agent gives the AI eval, the 1,100-command process launcher, all 8 EDA surfaces with screenshots, compile/ERC, DRC, OutJob outputs, native SchLib/PcbLib authoring with STEP 3D bodies, and dialog handling by PID.",-  "version": "0.2.55",+  "version": "0.2.56",   "hero": {     "type": "image",     "path": "screenshots/hero.png"
skills/altium-bom/SKILL.mdadded+92
@@ -0,0 +1,92 @@+---+name: altium-bom+description: >-+  The Altium bill of materials an AI can work with: altium_bom reads every schematic sheet of a+  project (device sheets included) with its parts' parameters, merges the board's placement and a+  variant, groups one line per manufacturer part number, checks what an order needs (missing MPNs and+  distributor numbers, a part number on two footprints, board-only or sheet-only parts, DNP), and+  writes bom.csv, a JLCPCB BOM + CPL, and Mouser and DigiKey BOM-upload files. altium-bom (container+  CLI) prices it with adom-parts-search (stock, price, lifecycle, JLCPCB-only parts) and writes the+  missing distributor numbers back onto the sheets with altium_sheet_params. Use when asked for an+  Altium BOM, to price or source an Altium board, to export for JLCPCB, Mouser or DigiKey, or to fill+  in part numbers. Trigger words: altium bom, bill of materials, price the bom, source the parts,+  jlcpcb bom, cpl, pick and place, mouser bom, digikey bom, missing part numbers, altium_bom,+  altium_sheet_params, altium-bom.+---++Parent skill: **altium-bridge**++# altium-bom++Altium's own BOM is an OutJob and an ActiveBOM document built for a person clicking through a grid.+These verbs give an AI the same project data as JSON, with the checks and files an order needs.++## Read the BOM++```bash+altium_bom '{"projectPath":"C:\\proj\\board.PrjPcb","boards":10}'+```++- Reads every `.SchDoc` the `.PrjPcb` lists, `[DeviceSheetN]` managed sheets included, then the board's+  placement (footprint, position from the board origin, rotation, side). A 46-sheet, 1046-part board+  takes about 20 s.+- `variant: "<description>"`: the variant's Not Fitted parts become DNP and its alternate parts are listed+  (`alternates[]`). DNP also comes from a `DNP` / `Do Not Populate` parameter or `Fitted=false`.+- One line per manufacturer part number (else per value + footprint), designators in natural order.+- Parameter names are matched across libraries: `MPN` = `Manufacturer Part Number` = `PartNumber` =+  `Mfr Part #`; `Mouser Part #`, `DigiKey Part #` / `Digi-Key Part Number`, `LCSC Part #` / `JLCPCB+  Part #`. Only those values are read from the parts (some libraries carry huge blob parameters).+- Graphical, Net Tie (no BOM) and Standard (no BOM) parts stay out (`checks.excludedKinds`).+- Each line says where its parts come from (`sourceLibrary`, `managed` for workspace and vault parts).++**checks**: `missingMpn`, `missingManufacturer`, `missingPartNumbers{mouser, digikey, lcsc}`,+`inconsistent` (one part number on two footprints, or two distributor numbers), `dnp`,+`excludedKinds`, `boardNotOnSheet` (placed on the board only: test pads, fiducials, mounting holes, and+the copies of a repeated multi-channel sheet), `sheetNotOnBoard` (not placed yet).++**files** (default `<project folder>\BOM\`, or `outputDir`; `write:false` for none):++| File | For |+|---|---|+| `bom.csv` | everything per line: quantity, designators, value, footprint, MPN, manufacturer, distributor numbers |+| `bom-jlcpcb.csv` + `cpl-jlcpcb.csv` | JLCPCB assembly (their column names; mm, Top/Bottom). Check rotations on JLCPCB's preview |+| `bom-mouser.csv`, `bom-digikey.csv` | the Mouser BOM Tool and DigiKey BOM Manager uploads, quantities times `boards` |+| `bom.json` | the reply's lines and checks |++## Price and fill from the container++`adom-wiki pkg install adom/altium-bridge` installs the `altium-bom` command.++```bash+altium-bom read  --project 'C:\proj\board.PrjPcb' --boards 10 --target <box>   # altium_bom -> bom.json+altium-bom price bom.json                                  # price.md, price.csv, suggestions.json+altium-bom fill  bom.json --suggestions bom-price/suggestions.json            # dry run+altium-bom fill  bom.json --suggestions bom-price/suggestions.json --apply    # write onto the sheets+```++- `price` makes ONE `adom-parts-search` call per line (Mouser, DigiKey and JLCPCB together). That spends a+  shared daily quota: price a BOM once, not in a loop.+- Flags per line: `NO_MPN`, `NOT_FOUND`, `NO_STOCK`, `LOW_STOCK` (less than the boards need),+  `JLCPCB_ONLY` (in stock for JLCPCB assembly, not at Mouser or DigiKey), `LIFECYCLE_<VENDOR>` (NRND,+  obsolete), `PN_DIFFERS_<VENDOR>` (the BOM's number is not the one the vendor lists for the MPN, often+  the reel/cut-tape twin).+- `fill` writes the distributor numbers a line lacks. It replaces a differing one only with `--replace`.++## Write parameters onto the sheets++```bash+altium_sheet_params '{"projectPath":"C:\\proj\\board.PrjPcb","designators":["R1","R2"],+                      "params":{"MPN":"RC0402FR-0710KL","Manufacturer":"YAGEO","Mouser Part #":"603-RC0402FR-0710KL"}}'+```++Each designator is found on whichever sheet holds it; a missing parameter is added hidden; each touched+sheet is saved (`save:false` to leave it). The placed parts change, not the library (`altium_set_params`+changes a library part). The board picks the parameters up with Design > Update PCB Document.++## Limits++- A repeated (multi-channel) sheet is read once: its copies' designators show in `boardNotOnSheet`.+- A workspace-managed part's description is not read from its vault (that call hung on Altium's example+  content); a `Description` parameter is used when the part has one.+- Variant support follows the documented `.PrjPcb` format; no example project with variants was at hand.+- `adom-parts-search` has no JLCPCB price; JLCPCB-only lines are not in the priced total.
skills/altium-wiki-import/SKILL.md+13−13
@@ -3,8 +3,8 @@ name: altium-wiki-import description: >-   Import a component from the Adom wiki into Altium — one part page, or a whole Cloud Library   (Colby's component cart) — with symbol, footprint, 3D body and Mouser/DigiKey/LCSC identity, in-  seconds, without foregrounding Altium. Covers the Cloud Library's Altium gap (export --format-  altium is phase 3; KiCad + Fusion ship today) and the working pipeline that closes it, plus the+  seconds, without foregrounding Altium. Covers the Cloud Library's own Altium export (export --format+  altium: libraries without distributor numbers), the bridge pipeline that adds them, plus the   background proof captures the wiki front-end needs. Read when asked to "add this wiki part to   Altium", "install my cloud library into Altium", or to prove an import worked. Trigger words —   wiki part to altium, cloud library, adom-wiki library, import component altium, kicad_sym to@@ -17,19 +17,19 @@ Parent skill: **altium-bridge**  ## What ships today -`adom-wiki library` (Cloud Libraries — component carts) exports **KiCad and Fusion**. Altium is-**phase 3**:+`adom-wiki library` (Cloud Libraries, i.e. component carts) exports **KiCad, Fusion and Altium**.+`adom-wiki library export <ref> --format altium -o lib.zip` gives a merged `.SchLib`, a `.PcbLib` with+deduplicated footprints and embedded STEP bodies, and `REPORT.json` (checked 2026-10-21 on a 7-part+library). Install it with `altium_install_library_bundle` or `altium_install_library`. -```-$ adom-wiki library availability <ref>      -> {"kicad":5,"fusion":5,"altium":0}-                                               per item: "no Altium assets declared"-$ adom-wiki library export <ref> --format altium-  LIB_FORMAT_UNAVAILABLE: format "altium" is not exportable yet-  "KiCad and Fusion ship today; the Altium lane (verified vendor assets, clone-based merge) is phase 3."-```+What that export does **not** carry: its symbols have only `Comment`, `Value` and `Manufacturer Part+Number`. They have no `Manufacturer` and no Mouser, DigiKey or LCSC numbers, though the parts' wiki pages+have them. So a board built from it has an unorderable BOM until those are added. Two ways: -Wiki component pages carry `.kicad_sym`, `.kicad_mod`, `.step`, `.glb`, `.lbr` — no Altium-native-assets, which is exactly what the exporter is telling you. **The bridge closes that gap today.**+- Add them after the install: `altium_set_params` per library part, or on the placed parts with+  `altium_sheet_params` (what `altium-bom fill` does from a pricing run; see **altium-bom**).+  `altium_bom` reads `Manufacturer Part Number` as the MPN.+- Or import part by part with the pipeline below, which writes the full identity in one step.  ## The pipeline (verified end to end, 2026-09-01) 
src/BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.2.55+0.2.56
src/agent/AdomAgent.pas.tmpl+148−11
@@ -196,23 +196,35 @@ Begin     End; End; +{ Runs of plain characters are copied whole: appending one character at a time is quadratic in+  DelphiScript, and one part's long parameter (a Mini PC example IC) took minutes to escape (2026-10-21). } Function JsonEsc(S : String) : String; Var-    I : Integer;+    I, Start, N : Integer;     C : Char;+    E : String; Begin     Result := '';-    For I := 1 To Length(S) Do+    N := Length(S);+    Start := 1;+    For I := 1 To N Do     Begin         C := S[I];-        If C = '"' Then Result := Result + '\"'-        Else If C = '\' Then Result := Result + '\\'-        Else If C = #13 Then Result := Result + '\r'-        Else If C = #10 Then Result := Result + '\n'-        Else If C = #9 Then Result := Result + '\t'-        Else If Ord(C) < 32 Then Result := Result + ' '-        Else Result := Result + C;+        E := '';+        If C = '"' Then E := '\"'+        Else If C = '\' Then E := '\\'+        Else If C = #13 Then E := '\r'+        Else If C = #10 Then E := '\n'+        Else If C = #9 Then E := '\t'+        Else If Ord(C) < 32 Then E := ' ';+        If E <> '' Then+        Begin+            If I > Start Then Result := Result + Copy(S, Start, I - Start);+            Result := Result + E;+            Start := I + 1;+        End;     End;+    If Start <= N Then Result := Result + Copy(S, Start, N - Start + 1); End;  Function JStr(Key : String; Val : String) : String;@@ -4376,6 +4388,38 @@ Begin         JStr('nets', AllPadNets(Comp)) + '}'; End; +{ bom_placement: every component's footprint, side and reference point relative to the board origin, for a+  pick-and-place file. Lean on purpose (no pads, no nets): a large board's full read takes minutes.+  x/y are RAW coords from the origin (10000 per mil), rotation in thousandths of a degree (locale-free). }+Function CmdBomPlacement(Lines : TStringList) : String;+Var+    Board : IPCB_Board;+    BIt   : IPCB_BoardIterator;+    Comp  : IPCB_Component;+    S     : String;+    N     : Integer;+Begin+    Board := PCBServer.GetCurrentPCBBoard;+    If Board = Nil Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('error','no current PCB board (open a .PcbDoc first)') + '}'; Exit; End;+    S := ''; N := 0;+    BIt := Board.BoardIterator_Create;+    BIt.AddFilter_ObjectSet(MkSet(eComponentObject));+    BIt.AddFilter_LayerSet(AllLayers);+    BIt.AddFilter_Method(eProcessAll);+    Comp := BIt.FirstPCBObject;+    While Comp <> Nil Do+    Begin+        If S <> '' Then S := S + ',';+        S := S + '[' + '"' + JsonEsc(Comp.Name.Text) + '","' + JsonEsc(Comp.Pattern) + '",' ++            IntToStr(Comp.x - Board.XOrigin) + ',' + IntToStr(Comp.y - Board.YOrigin) + ',' ++            IntToStr(Round(Comp.Rotation * 1000)) + ',' + JBool(Comp.Layer = eBottomLayer) + ']';+        N := N + 1;+        Comp := BIt.NextPCBObject;+    End;+    Board.BoardIterator_Destroy(BIt);+    Result := '{' + JRaw('ok','true') + ',' + JRaw('count', IntToStr(N)) + ',' + JRaw('comps', '[' + S + ']') + '}';+End;+ { list_designators: every component designator on the current board } Function CmdListDesignators(Lines : TStringList) : String; Var@@ -4632,7 +4676,7 @@ Var     Pin  : ISch_Pin;     Prm  : ISch_Parameter;     QIt  : ISch_Iterator;-    Want, S, PS, MS : String;+    Want, S, PS, MS, PF, PV, Desc : String;     N, Limit, PN : Integer;     WantPins, WantParams : Boolean; Begin@@ -4644,6 +4688,7 @@ Begin     Limit := StrToIntDef(Param(Lines, 'limit'), 500);     WantPins := Param(Lines, 'pins') <> '0';     WantParams := Param(Lines, 'params') = '1';+    PF := UpperCase(Param(Lines, 'paramFilter'));     S := ''; N := 0;     Iter := Doc.SchIterator_Create;     Iter.AddFilter_ObjectSet(MkSet(eSchComponent));@@ -4679,17 +4724,37 @@ Begin                 While (Prm <> Nil) And (PN < 40) Do                 Begin                     If MS <> '' Then MS := MS + ',';-                    MS := MS + '{' + JStr('name', Prm.Name) + ',' + JStr('value', Prm.Text) + '}';+                    { paramFilter=|NAME|NAME| reads only those values: an example IC's three 'Available Preview Images'+                      parameters held so much that reading them took minutes (2026-10-21). Long values are cut. }+                    If (Param(Lines, 'paramNamesOnly') = '1') Or ((PF <> '') And (Pos('|' + UpperCase(Prm.Name) + '|', PF) = 0)) Then+                        MS := MS + '{' + JStr('name', Prm.Name) + '}'+                    Else+                    Begin+                        PV := Prm.Text;+                        If Length(PV) > 2000 Then+                            MS := MS + '{' + JStr('name', Prm.Name) + ',' + JStr('value', Copy(PV, 1, 2000)) + ',' + JRaw('truncatedFrom', IntToStr(Length(PV))) + '}'+                        Else+                            MS := MS + '{' + JStr('name', Prm.Name) + ',' + JStr('value', PV) + '}';+                    End;                     PN := PN + 1;                     Prm := QIt.NextSchObject;                 End;                 Comp.SchIterator_Destroy(QIt);             End;+            { a workspace-managed part's description is fetched from its vault: on the Bluetooth Sentinel+              example's Altium Content Vault parts that call never returned (2026-10-21). Their item GUID says+              where they live; the description of an unmanaged part is read as before. }+            If Comp.ItemGUID = '' Then Desc := Comp.ComponentDescription Else Desc := '';             If S <> '' Then S := S + ',';             S := S + '{' + JRaw('params', '[' + MS + ']') + ',' + JStr('designator', Comp.Designator.Text) + ',' +                 JRaw('partId', IntToStr(Comp.CurrentPartID)) + ',' +                 JStr('libReference', Comp.LibReference) + ',' +                 JStr('comment', Comp.Comment.Text) + ',' ++                JStr('description', Desc) + ',' ++                JStr('itemGuid', Comp.ItemGUID) + ',' ++                JStr('vaultGuid', Comp.VaultGUID) + ',' ++                JStr('sourceLibrary', Comp.SourceLibraryName) + ',' ++                JRaw('kind', IntToStr(Comp.ComponentKind)) + ',' +                 JRaw('xMil', IntToStr(Round(CoordToMils(Comp.Location.X)))) + ',' +                 JRaw('yMil', IntToStr(Round(CoordToMils(Comp.Location.Y)))) + ',' +                 JRaw('orientation', IntToStr(Comp.Orientation)) + ',' +@@ -4702,6 +4767,76 @@ Begin     Result := '{' + JRaw('ok','true') + ',' + JStr('document', Doc.DocumentName) + ',' + JRaw('count', IntToStr(N)) + ',' + JRaw('components', '[' + S + ']') + '}'; End; +{ sheet_params: set parameters on PLACED components of the CURRENT schematic sheet (the BOM write-back).+  designators=R1,R2 (every part of a multi-part component)  params=Key=Value;Key=Value+  A missing parameter is added hidden; an existing one gets the value (PreProcess/PostProcess around the edit;+  c_BroadcastBroadcast robot messages are not declared in AD 26's script engine). Save with altium_save. }+Function CmdSheetParams(Lines : TStringList) : String;+Var+    Doc  : ISch_Document;+    Iter : ISch_Iterator;+    Comp : ISch_Component;+    Want, Items : TStringList;+    Spec, K, V, Found, Missing : String;+    I, P, NComp, NSet : Integer;+Begin+    Doc := SchServer.GetCurrentSchDocument;+    If (Doc = Nil) Or (Doc.ObjectID = eSchLib) Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('errorCode','no_current_schematic') + ',' + JStr('error','no current schematic sheet (open a .SchDoc first)') + '}'; Exit; End;+    Want := TStringList.Create;+    Want.Delimiter := ',';+    Want.StrictDelimiter := True;+    Want.DelimitedText := UpperCase(Param(Lines, 'designators'));+    Items := SplitSemi(Param(Lines, 'params'));+    If (Want.Count = 0) Or (Items.Count = 0) Then+    Begin+        Want.Free; Items.Free;+        Result := '{' + JRaw('ok','false') + ',' + JStr('errorCode','bad_args') + ',' + JStr('error','designators and params are required') + '}';+        Exit;+    End;+    Found := ''; NComp := 0; NSet := 0;+    SchServer.ProcessControl.PreProcess(Doc, '');+    Iter := Doc.SchIterator_Create;+    Iter.AddFilter_ObjectSet(MkSet(eSchComponent));+    Comp := Iter.FirstSchObject;+    While Comp <> Nil Do+    Begin+        If Want.IndexOf(UpperCase(Comp.Designator.Text)) >= 0 Then+        Begin+            For I := 0 To Items.Count - 1 Do+            Begin+                Spec := Items[I];+                P := Pos('=', Spec);+                If P > 0 Then+                Begin+                    K := Trim(Copy(Spec, 1, P - 1));+                    V := Trim(Copy(Spec, P + 1, Length(Spec)));+                    If K <> '' Then Begin SetSchParam(Comp, K, V); NSet := NSet + 1; End;+                End;+            End;+            If Pos('"' + Comp.Designator.Text + '"', Found) = 0 Then+            Begin+                If Found <> '' Then Found := Found + ',';+                Found := Found + '"' + JsonEsc(Comp.Designator.Text) + '"';+            End;+            NComp := NComp + 1;+        End;+        Comp := Iter.NextSchObject;+    End;+    Doc.SchIterator_Destroy(Iter);+    SchServer.ProcessControl.PostProcess(Doc, '');+    Doc.GraphicallyInvalidate;+    Missing := '';+    For I := 0 To Want.Count - 1 Do+        If Pos('"' + UpperCase(Want[I]) + '"', UpperCase(Found)) = 0 Then+        Begin+            If Missing <> '' Then Missing := Missing + ',';+            Missing := Missing + '"' + JsonEsc(Want[I]) + '"';+        End;+    Want.Free; Items.Free;+    Result := '{' + JRaw('ok','true') + ',' + JStr('document', Doc.DocumentName) + ',' + JRaw('parts', IntToStr(NComp)) + ',' ++        JRaw('paramsSet', IntToStr(NSet)) + ',' + JRaw('designators', '[' + Found + ']') + ',' + JRaw('notOnSheet', '[' + Missing + ']') + '}';+End;+ { copper_layers: the full copper set (top, mid 1-30, planes 1-16, bottom) with names and visibility } Function CmdCopperLayers(Lines : TStringList) : String; Var@@ -7043,11 +7178,13 @@ Begin     Else If Cmd = 'pcb_report' Then Result := CmdPcbReport(Lines)     Else If Cmd = 'list_designators' Then Result := CmdListDesignators(Lines)     Else If Cmd = 'component_data' Then Result := CmdComponentData(Lines)+    Else If Cmd = 'bom_placement' Then Result := CmdBomPlacement(Lines)     Else If Cmd = 'component_pins' Then Result := CmdComponentPins(Lines)     Else If Cmd = 'list_nets' Then Result := CmdListNets(Lines)     Else If Cmd = 'layers' Then Result := CmdLayers(Lines)     Else If Cmd = 'net_connections' Then Result := CmdNetConnections(Lines)     Else If Cmd = 'schematic_data' Then Result := CmdSchematicData(Lines)+    Else If Cmd = 'sheet_params' Then Result := CmdSheetParams(Lines)     Else If Cmd = 'copper_layers' Then Result := CmdCopperLayers(Lines)     Else If Cmd = 'read_rules' Then Result := CmdReadRules(Lines)     Else If Cmd = 'set_layer_visibility' Then Result := CmdSetLayerVisibility(Lines)
src/bridge.json+6−2
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "altium",   "displayName": "Altium Designer",-  "version": "0.2.55",+  "version": "0.2.56",   "description": "Drive Altium Designer from Adom: a resident DelphiScript agent (eval + the 1,100-command process launcher), the X2 -R command line, UIA over the VCL shell, dialogs by PID, screenshots of every Altium window, and Altium 365.",   "homepage": "https://wiki.adom.inc/adom/altium-bridge",   "docs": "https://wiki.adom.inc/adom/altium-bridge",@@ -46,6 +46,7 @@     "altium_apply_silkscreen",     "altium_board_shape",     "altium_board_summary",+    "altium_bom",     "altium_build_board",     "altium_build_footprint",     "altium_build_schematic",@@ -145,6 +146,7 @@     "altium_set_params",     "altium_set_visibility",     "altium_setup_trial",+    "altium_sheet_params",     "altium_show_2d_board",     "altium_show_3d_board",     "altium_show_3d_chip",@@ -268,7 +270,9 @@       "place_on_pcb": 90,       "ensure_licensed": 300,       "setup_trial": 300,-      "a365_files_upload": 120+      "a365_files_upload": 120,+      "bom": 600,+      "sheet_params": 300     },     "prefixes": [       {
tools/bom_tool.pyadded+289
@@ -0,0 +1,289 @@+#!/usr/bin/env python3+"""altium-bom: the BOM work Altium leaves to a spreadsheet, from the container.++  altium-bom read  --project <C:\\...\\board.PrjPcb> [--variant V] [--boards N] [--target BOX] [--out bom.json]+      altium_bom through adom-bridge: every sheet's placed parts and parameters merged with the board,+      grouped one line per MPN, checked; the files land beside the project on Windows (BOM\\) and the+      JSON here.+  altium-bom price bom.json [--boards N] [--out DIR]+      One adom-parts-search call per line (Mouser, DigiKey and JLCPCB in parallel; mind the shared daily+      quota): stock against what N boards need, unit price, lead time, lifecycle, the distributor+      numbers each vendor actually sells the MPN under. Writes price.md, price.csv and suggestions.json.+  altium-bom fill bom.json --suggestions suggestions.json --project <PrjPcb> [--apply] [--target BOX]+      The distributor numbers the BOM lacks (or has wrong), written back onto the schematic sheets with+      altium_sheet_params. Without --apply it only prints the plan.++Every command prints OK:/ERROR: first and Hint: lines after, like the other Adom CLIs.+"""+import argparse+import csv+import json+import os+import re+import subprocess+import sys+from pathlib import Path++VENDORS = ("mouser", "digikey", "jlcpcb")+FIELD = {"mouser": "mouser", "digikey": "digikey", "jlcpcb": "lcsc"}+PARAM = {"mouser": "Mouser Part #", "digikey": "DigiKey Part #", "jlcpcb": "LCSC Part #"}+BAD_LIFECYCLE = re.compile(r"obsolete|nrnd|not recommended|end of life|eol|discontinued|last time buy", re.I)+++def ok(msg, hints=()):+    print("OK: " + msg)+    for h in hints:+        if h:+            print("Hint: " + h)+++def err(msg, hints=()):+    print("ERROR: " + msg)+    for h in hints:+        if h:+            print("Hint: " + h)+    sys.exit(1)+++def bridge(verb, args, target=None, thread=None, timeout=900):+    cmd = ["adom-bridge", "--ai-thread", thread or os.environ.get("ADOM_AI_THREAD", "altium-bom")]+    if target:+        cmd += ["--target", target]+    cmd += [verb, json.dumps(args)]+    try:+        out = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, stdin=subprocess.DEVNULL).stdout+    except subprocess.TimeoutExpired:+        err("%s did not answer within %d s" % (verb, timeout))+    except FileNotFoundError:+        err("adom-bridge is not on PATH", ["This runs in an Adom container with Adom Bridge connected to the Windows machine running Altium."])+    try:+        d = json.loads(out)+    except ValueError:+        err("%s answered something that is not JSON: %s" % (verb, out[:300]))+    o = d.get("output", d)+    if isinstance(o, str) and o.startswith("{"):+        try:+            o = json.loads(o)+        except ValueError:+            pass+    return o if isinstance(o, dict) else d+++def money(s):+    if s in (None, ""):+        return None+    m = re.search(r"[\d.,]+", str(s))+    if not m:+        return None+    try:+        return float(m.group(0).replace(",", ""))+    except ValueError:+        return None+++def norm(pn):+    return re.sub(r"[\s\-_/.]", "", (pn or "").upper())+++def search(mpn):+    """adom-parts-search search <mpn>: {vendor: [hits]} (exact MPN matches first)."""+    try:+        r = subprocess.run(["adom-parts-search", "search", mpn, "--limit", "4"], capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL)+    except FileNotFoundError:+        err("adom-parts-search is not installed", ["adom-wiki pkg install adom/adom-parts-search"])+    except subprocess.TimeoutExpired:+        return {"error": "timed out"}+    t = r.stdout+    try:+        j = json.loads(t[t.index("{"):])+    except ValueError:+        return {"error": (t or r.stderr)[:200]}+    out = {}+    for v in VENDORS:+        hits = (j.get(v) or {}).get("hits") or []+        out[v] = sorted(hits, key=lambda h: norm(h.get("mpn")) != norm(mpn))+        if (j.get(v) or {}).get("error"):+            out[v + "_error"] = j[v]["error"]+    return out+++def cmd_read(a):+    if not a.project:+        err("--project <C:\\...\\board.PrjPcb> is required")+    args = {"projectPath": a.project, "boards": a.boards, "timeout": 600}+    if a.variant:+        args["variant"] = a.variant+    if a.output_dir:+        args["outputDir"] = a.output_dir+    r = bridge("altium_bom", args, a.target, timeout=900)+    if not r.get("success"):+        err("altium_bom: %s" % (r.get("error") or r.get("errorCode") or "failed"), [r.get("_hint")])+    out = Path(a.out or "bom.json")+    out.write_text(json.dumps(r, indent=1))+    c = r.get("checks") or {}+    gaps = ", ".join("%d without %s" % (len(v), k) for k, v in (c.get("missingPartNumbers") or {}).items() if v)+    ok("%d lines, %d fitted parts from %d sheet(s): %s" % (r.get("lineCount", 0), r.get("fittedParts", 0), len(r.get("sheets") or []), out),+       ["Files on the Windows machine: " + ", ".join(Path(f.replace("\\", "/")).name for f in r.get("files") or []),+        ("%d part(s) have no MPN: %s" % (len(c["missingMpn"]), ", ".join(c["missingMpn"][:12]))) if c.get("missingMpn") else "",+        ("Distributor numbers missing: " + gaps) if gaps else "",+        ("On the board but not on a sheet: " + ", ".join(c["boardNotOnSheet"][:12])) if c.get("boardNotOnSheet") else "",+        ("On a sheet but not on the board: " + ", ".join(c["sheetNotOnBoard"][:12])) if c.get("sheetNotOnBoard") else "",+        ("%d line(s) mix values for one part (checks.inconsistent)" % len(c["inconsistent"])) if c.get("inconsistent") else "",+        "Next: altium-bom price %s --boards %d" % (out, a.boards)])+++def cmd_price(a):+    bom = json.loads(Path(a.bom).read_text())+    lines = bom.get("lines") or []+    boards = a.boards or bom.get("boards") or 1+    out_dir = Path(a.out or Path(a.bom).with_suffix("").name + "-price")+    out_dir.mkdir(parents=True, exist_ok=True)+    rows, suggestions, flags_all = [], [], {}+    total = {v: 0.0 for v in VENDORS}+    best_total, unpriced = 0.0, []+    for i, l in enumerate(lines, 1):+        mpn = l.get("mpn") or ""+        need = int(l.get("quantity") or 0) * boards+        refs = ",".join(l.get("designators") or [])+        row = {"line": i, "designators": refs, "value": l.get("value", ""), "mpn": mpn, "manufacturer": l.get("manufacturer", ""), "need": need}+        flags = []+        if not mpn:+            flags.append("NO_MPN")+            row["flags"] = flags+            rows.append(row)+            unpriced.append(refs)+            continue+        found = search(mpn)+        if found.get("error"):+            flags.append("SEARCH_FAILED")+        best = None+        for v in VENDORS:+            hits = found.get(v) or []+            exact = [h for h in hits if norm(h.get("mpn")) == norm(mpn)]+            h = exact[0] if exact else None+            have = l.get(FIELD[v]) or ""+            if h:+                price = money(h.get("price_100")) if need >= 100 and money(h.get("price_100")) else money(h.get("unit_price"))+                stock = h.get("stock")+                row[v] = {"pn": h.get("vendor_pn"), "stock": stock, "unit": price, "lead": h.get("lead_time") or "", "flags": h.get("flags") or []}+                if price is not None:+                    total[v] += price * need+                    if (stock or 0) >= need and (best is None or price < best[1]):+                        best = (v, price)+                life = " ".join(str(x) for x in (h.get("flags") or [])) + " " + str(h.get("lifecycle") or "")+                if BAD_LIFECYCLE.search(life):+                    flags.append("LIFECYCLE_%s" % v.upper())+                if have and norm(have) != norm(h.get("vendor_pn")):+                    # the BOM's number may be a reel vs cut-tape twin; say so, do not overwrite silently+                    flags.append("PN_DIFFERS_%s" % v.upper())+                    suggestions.append({"designators": l.get("designators"), "param": PARAM[v], "value": h.get("vendor_pn"), "was": have, "why": "%s sells %s as %s" % (v, mpn, h.get("vendor_pn")), "replace": True})+                elif not have:+                    suggestions.append({"designators": l.get("designators"), "param": PARAM[v], "value": h.get("vendor_pn"), "was": "", "why": "%s sells %s as %s" % (v, mpn, h.get("vendor_pn")), "replace": False})+            else:+                row[v] = None+        stock = {v: ((row.get(v) or {}).get("stock") or 0) for v in VENDORS}+        dist = max(stock["mouser"], stock["digikey"])+        if not any(row.get(v) for v in VENDORS):+            flags.append("NOT_FOUND")+        elif max(stock.values()) == 0:+            flags.append("NO_STOCK")+        elif max(stock.values()) < need:+            flags.append("LOW_STOCK")+        elif dist < need:+            # in stock only for JLCPCB assembly: buying it from a distributor means their lead time+            flags.append("JLCPCB_ONLY")+        if best:+            row["best"] = {"vendor": best[0], "unit": best[1], "extended": round(best[1] * need, 2)}+            best_total += best[1] * need+        else:+            unpriced.append(refs)+        row["flags"] = flags+        for f in flags:+            flags_all.setdefault(f, []).append(refs)+        rows.append(row)+    # the files+    md = ["# BOM pricing: %d line(s), %d board(s)" % (len(lines), boards), "",+          "Searched with adom-parts-search (Mouser, DigiKey, JLCPCB). Prices are the vendors' list prices at the time of the search; stock is a snapshot.", "",+          "| # | Designators | Value | MPN | Need | Mouser (stock, unit) | DigiKey (stock, unit) | JLCPCB (stock) | Best | Flags |", "|---|---|---|---|---|---|---|---|---|---|"]+    fmt = lambda c, show_price=True: "" if not c else "%s (%s%s)" % (c["pn"], c["stock"], (", $%.4g" % c["unit"]) if show_price and c.get("unit") is not None else "")+    for r in rows:+        md.append("| %d | %s | %s | %s | %d | %s | %s | %s | %s | %s |" % (r["line"], r["designators"], r["value"], r["mpn"], r["need"], fmt(r.get("mouser")), fmt(r.get("digikey")), fmt(r.get("jlcpcb"), False),+                  ("%s $%.2f" % (r["best"]["vendor"], r["best"]["extended"])) if r.get("best") else "", " ".join(r.get("flags") or [])))+    md += ["", "**Cheapest in-stock total:** $%.2f for %d board(s) (%s line(s) not priced: %s)" % (best_total, boards, len(unpriced), "; ".join(unpriced[:10]) or "none")]+    (out_dir / "price.md").write_text("\n".join(md) + "\n")+    with open(out_dir / "price.csv", "w", newline="") as f:+        w = csv.writer(f)+        w.writerow(["Line", "Designators", "Value", "MPN", "Manufacturer", "Need", "Mouser PN", "Mouser stock", "Mouser unit", "DigiKey PN", "DigiKey stock", "DigiKey unit", "LCSC", "JLCPCB stock", "Best vendor", "Best extended", "Flags"])+        for r in rows:+            g = lambda v, k: (r.get(v) or {}).get(k, "") if r.get(v) else ""+            w.writerow([r["line"], r["designators"], r["value"], r["mpn"], r["manufacturer"], r["need"], g("mouser", "pn"), g("mouser", "stock"), g("mouser", "unit"),+                        g("digikey", "pn"), g("digikey", "stock"), g("digikey", "unit"), g("jlcpcb", "pn"), g("jlcpcb", "stock"),+                        (r.get("best") or {}).get("vendor", ""), (r.get("best") or {}).get("extended", ""), " ".join(r.get("flags") or [])])+    (out_dir / "suggestions.json").write_text(json.dumps({"project": bom.get("projectPath"), "suggestions": suggestions}, indent=1))+    (out_dir / "price.json").write_text(json.dumps({"boards": boards, "rows": rows, "bestTotal": round(best_total, 2), "flags": flags_all}, indent=1))+    worry = {k: v for k, v in flags_all.items() if k in ("NO_STOCK", "LOW_STOCK", "NOT_FOUND", "NO_MPN", "JLCPCB_ONLY") or k.startswith("LIFECYCLE")}+    ok("%d line(s) priced for %d board(s): cheapest in-stock total $%.2f; %s" % (len(rows) - len(unpriced), boards, best_total, out_dir / "price.md"),+       ["%s: %s" % (k, "; ".join(v[:8])) for k, v in sorted(worry.items())] ++       ["%d distributor number(s) to add or correct: altium-bom fill %s --suggestions %s --project <PrjPcb> (dry run; --apply writes them onto the sheets)" % (len(suggestions), a.bom, out_dir / "suggestions.json") if suggestions else "",+        "A PN_DIFFERS flag is often a reel vs cut-tape twin of the same part: check before replacing (fill only replaces with --replace)."])+++def cmd_fill(a):+    sug = json.loads(Path(a.suggestions).read_text()).get("suggestions") or []+    if not a.replace:+        sug = [s for s in sug if not s.get("replace")]+    if not sug:+        ok("nothing to write", ["Every line already has the distributor numbers (run with --replace to also correct the PN_DIFFERS ones)."])+        return+    # one sheet_params call per designator set: all of that line's parameters together+    plan = {}+    for s in sug:+        key = ",".join(s["designators"])+        plan.setdefault(key, {})[s["param"]] = s["value"]+    for refs, params in plan.items():+        print("  %s: %s" % (refs, ", ".join("%s=%s" % kv for kv in params.items())))+    if not a.apply:+        ok("%d write(s) planned (dry run)" % len(plan), ["Run again with --apply to write them onto the sheets (each touched sheet is saved)."])+        return+    project = a.project or json.loads(Path(a.suggestions).read_text()).get("project")+    if not project:+        err("--project <PrjPcb> is required")+    done, failed = 0, []+    for refs, params in plan.items():+        r = bridge("altium_sheet_params", {"projectPath": project, "designators": refs.split(","), "params": params}, a.target, timeout=400)+        if r.get("success"):+            done += 1+        else:+            failed.append("%s (%s)" % (refs, r.get("error") or r.get("errorCode")))+    if failed:+        err("%d of %d write(s) failed: %s" % (len(failed), len(plan), "; ".join(failed[:6])), ["altium-bom read again shows what landed."])+    ok("%d line(s) updated on the sheets" % done, ["altium-bom read --project %s re-reads the BOM; Design > Update PCB Document carries the parameters to the board." % project])+++def main():+    ap = argparse.ArgumentParser(prog="altium-bom", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)+    sub = ap.add_subparsers(dest="cmd", required=True)+    r = sub.add_parser("read")+    r.add_argument("--project")+    r.add_argument("--variant")+    r.add_argument("--boards", type=int, default=1)+    r.add_argument("--output-dir")+    r.add_argument("--target")+    r.add_argument("--out")+    p = sub.add_parser("price")+    p.add_argument("bom")+    p.add_argument("--boards", type=int)+    p.add_argument("--out")+    f = sub.add_parser("fill")+    f.add_argument("bom")+    f.add_argument("--suggestions", required=True)+    f.add_argument("--project")+    f.add_argument("--apply", action="store_true")+    f.add_argument("--replace", action="store_true")+    f.add_argument("--target")+    a = ap.parse_args()+    {"read": cmd_read, "price": cmd_price, "fill": cmd_fill}[a.cmd](a)+++if __name__ == "__main__":+    main()
uninstall.sh+2−2
@@ -1,8 +1,8 @@ #!/usr/bin/env bash set -euo pipefail for HOME_DIR in "$HOME/.claude/skills" "$HOME/.codex/skills"; do-  rm -rf "$HOME_DIR/altium-bridge" "$HOME_DIR/altium-bridge-plan" "$HOME_DIR/altium-part-import" "$HOME_DIR/altium-kicad-import" "$HOME_DIR/altium-install" "$HOME_DIR/altium-libraries" "$HOME_DIR/altium-boards" "$HOME_DIR/altium-outputs" "$HOME_DIR/altium-instances" "$HOME_DIR/altium-licensing" "$HOME_DIR/altium-uia" "$HOME_DIR/altium-wiki-import" "$HOME_DIR/altium-a365" "$HOME_DIR/altium-bridge-dev" "$HOME_DIR/altium-bridge-publish"+  rm -rf "$HOME_DIR/altium-bridge" "$HOME_DIR/altium-bridge-plan" "$HOME_DIR/altium-part-import" "$HOME_DIR/altium-kicad-import" "$HOME_DIR/altium-install" "$HOME_DIR/altium-libraries" "$HOME_DIR/altium-boards" "$HOME_DIR/altium-outputs" "$HOME_DIR/altium-instances" "$HOME_DIR/altium-licensing" "$HOME_DIR/altium-uia" "$HOME_DIR/altium-wiki-import" "$HOME_DIR/altium-a365" "$HOME_DIR/altium-bom" "$HOME_DIR/altium-bridge-dev" "$HOME_DIR/altium-bridge-publish" done rm -rf "$HOME/.local/share/adom/altium-bridge"-rm -f "$HOME/.local/bin/altium-dashboard"+rm -f "$HOME/.local/bin/altium-dashboard" "$HOME/.local/bin/altium-kicad2altium" "$HOME/.local/bin/altium-symbol-modes" "$HOME/.local/bin/altium-kicad-downgrade" "$HOME/.local/bin/altium-bom" echo "OK: altium-bridge skills + dashboard removed"