app
Altium - the Altium Bridge
Public Made by Adomby adom
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.
main
Colby Knox
0.2.52 source (not yet released, needs signing): importing a part again replaces its 3D body instead of stacking a seco…
54bdc45
1d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101
//! Library authoring — thin views over the agent's library commands, plus the two fat lanes:
//! `import_part` (one call imports a whole part: footprint spec, STEP body, symbol modes, models,
//! params, save) and `install_library_bundle` (the no-AI web-control lane: staged zip →
//! Installed Libraries, hardened for untrusted zips).
use crate::support::{self, s, s_or, timeout_of};
use crate::{ab, agent, shot};
use serde_json::{json, Value};
use std::sync::Mutex;
use std::time::{Duration, Instant};
/// `_lib_call`: gate, optionally open a library first, then one agent command.
fn lib_call(args: &Value, cmd: &str, params: Vec<(&str, String)>, open_path: Option<&str>, tdef: u64) -> Value {
if let Some(g) = support::need_licensed() {
return g;
}
if let Some(p) = open_path {
let r = support::agent_open(p, 60);
if r.get("success").and_then(|b| b.as_bool()) != Some(true) {
return r;
}
}
agent::call(cmd, ¶ms, None, timeout_of(args, tdef))
}
/// `_sch_lib_verb`: gate, open args.libraryPath if given (best effort), one agent command.
fn sch_lib_verb(args: &Value, cmd: &str, params: Vec<(&str, String)>, tdef: u64) -> Value {
if let Some(g) = support::need_licensed() {
return g;
}
if let Some(p) = s(args, "libraryPath") {
if let Some(e) = support::must_open(&p, 60) { return e; }
}
agent::call(cmd, ¶ms, None, timeout_of(args, tdef))
}
fn with_shot(mut r: Value) -> Value {
if r.get("success").and_then(|b| b.as_bool()) == Some(true) {
r["shot"] = shot::shot_of_main();
} else {
r["shot"] = Value::Null;
}
r["windowEtiquette"] = json!("background");
r
}
pub fn create_footprint(args: &Value) -> Value {
if let Some(g) = support::need_licensed() {
return g;
}
for k in ["name", "libraryPath"] {
if s(args, k).unwrap_or_default().is_empty() {
return json!({"success": false, "error": format!("{k} is required"),
"_hint": "pads: 'des,x_mil,y_mil,w_mil,h_mil,rect|round,hole_mil;...'"});
}
}
let r = agent::call("create_footprint",
&[("name", s_or(args, "name", "")), ("libraryPath", s_or(args, "libraryPath", "")),
("description", s_or(args, "description", "")), ("pads", s_or(args, "pads", ""))],
None, timeout_of(args, 90));
let mut r = with_shot(r);
if r.get("_hint").is_none() {
r["_hint"] = json!("Footprint registered in the (new or opened) PcbLib and made current; the library is NOT saved yet — altium_save {filePath} or altium_run_command Client:Save... shot.image shows it.");
}
r
}
pub fn create_symbol(args: &Value) -> Value {
if let Some(g) = support::need_licensed() {
return g;
}
for k in ["name", "libraryPath"] {
if s(args, k).unwrap_or_default().is_empty() {
return json!({"success": false, "error": format!("{k} is required"),
"_hint": "pins: 'des,name,left|right|top|bottom,offset_mil;...'"});
}
}
let r = agent::call("create_symbol",
&[("name", s_or(args, "name", "")), ("libraryPath", s_or(args, "libraryPath", "")),
("description", s_or(args, "description", "")), ("designator", s_or(args, "designator", "U")),
("body_w_mil", s_or(args, "bodyWidthMil", "800")), ("body_h_mil", s_or(args, "bodyHeightMil", "600")),
("pins", s_or(args, "pins", ""))],
None, timeout_of(args, 90));
let mut r = with_shot(r);
if r.get("_hint").is_none() {
r["_hint"] = json!("Symbol added to the SchLib and made current (library unsaved). shot.image shows it.");
}
r
}
pub fn build_footprint(args: &Value) -> Value {
if let Some(g) = support::need_licensed() {
return g;
}
if s(args, "libraryPath").unwrap_or_default().is_empty() || s(args, "spec").unwrap_or_default().is_empty() {
return json!({"success": false, "error": "libraryPath (.PcbLib) and spec (Windows path to a kicad2altium spec) are required"});
}
with_shot(agent::call("build_footprint",
&[("libraryPath", s_or(args, "libraryPath", "")), ("spec", s_or(args, "spec", ""))],
None, timeout_of(args, 240)))
}
pub fn build_symbol_modes(args: &Value) -> Value {
if let Some(g) = support::need_licensed() {
return g;
}
if s(args, "libraryPath").unwrap_or_default().is_empty() || s(args, "spec").unwrap_or_default().is_empty() {
return json!({"success": false, "error": "libraryPath (.SchLib) and spec (Windows path to a symbol_modes spec) are required"});
}
with_shot(agent::call("build_symbol_modes",
&[("libraryPath", s_or(args, "libraryPath", "")), ("spec", s_or(args, "spec", ""))],
None, timeout_of(args, 240)))
}
/// `keepExisting:true` (or "true"/"1") is the opt-out for a deliberate second body; anything else
/// means add_body REPLACES the footprint's existing 3D bodies.
fn keep_existing(args: &Value) -> bool {
match args.get("keepExisting") {
Some(Value::Bool(b)) => *b,
Some(Value::String(t)) => matches!(t.trim().to_ascii_lowercase().as_str(), "true" | "1" | "yes"),
Some(Value::Number(n)) => n.as_i64() == Some(1),
_ => false,
}
}
/// The add_body `_hint`, from the agent's reply data: says what was replaced, and flags the two
/// ways a footprint can still end up with stacked bodies (a replace that did not take, or several
/// footprints sharing the name so the body went to the first of them only).
fn body_hint(d: &Value, keep: bool) -> String {
let n = |k: &str| d.get(k).and_then(|v| v.as_i64()).unwrap_or(0);
let (replaced, now, same) = (n("replacedBodies"), n("bodiesNow"), n("sameNameFootprints"));
let fp = d.get("footprint").and_then(|v| v.as_str()).unwrap_or("the footprint");
let mut h = if keep {
format!("keepExisting: the new body was ADDED next to the {} already on {fp}; it now has {now}.", n("bodiesBefore"))
} else if replaced > 0 {
format!("Replaced {replaced} existing 3D bod{} on {fp} with the new one (a re-import replaces, never stacks).",
if replaced == 1 { "y" } else { "ies" })
} else {
format!("{fp} had no 3D body; added one.")
};
if !keep && now > 1 {
h.push_str(&format!(" WARNING: {fp} still reads {now} bodies after a replace: altium_footprint_primitives to see them, then altium_remove_footprint + re-import."));
}
if same > 1 {
h.push_str(&format!(" WARNING: {same} footprints in this library are named {fp}; the body went to the FIRST only. Remove the stale copies (altium_remove_footprint) and re-import."));
}
h.push_str(" Then altium_save {filePath: libraryPath}; placed parts pick the change up with PCB:UpdateFootprints.");
h
}
pub fn add_body(args: &Value) -> Value {
let lib = match s(args, "libraryPath") {
Some(l) if !l.is_empty() => l,
_ => return json!({"success": false, "error": "libraryPath (.PcbLib) is required",
"_hint": "step:'C:/…/part.step' for a real model, else bwMil/bhMil/hMil/soMil for an extruded box"}),
};
let keep = keep_existing(args);
let mut r = lib_call(args, "add_body",
vec![("name", s_or(args, "footprintName", "")), ("step", s_or(args, "step", "")),
("bw_mil", s_or(args, "bwMil", "100")), ("bh_mil", s_or(args, "bhMil", "100")),
("h_mil", s_or(args, "hMil", "40")), ("so_mil", s_or(args, "soMil", "0")),
("keep", if keep { "1".into() } else { "0".into() })],
Some(&lib), 90);
if r.get("success").and_then(|b| b.as_bool()) == Some(true) {
let d = r.get("data").cloned().unwrap_or(json!({}));
r["replacedBodies"] = d.get("replacedBodies").cloned().unwrap_or(Value::Null);
r["_hint"] = json!(body_hint(&d, keep));
// canvas-added bodies land 50000 mil from the pads in the stored frame; realign
let n = agent::call("normalize_bodies", &[("name", s_or(args, "footprintName", ""))], None, Duration::from_secs(90));
r["normalized"] = n.get("data").and_then(|d| d.get("shifts")).cloned().unwrap_or(Value::Null);
r["shot"] = shot::shot_of_main();
} else {
r["shot"] = Value::Null;
}
r
}
pub fn normalize_bodies(args: &Value) -> Value {
sch_lib_verb(args, "normalize_bodies", vec![("name", s_or(args, "footprintName", ""))], 120)
}
pub fn plate_vias(args: &Value) -> Value {
sch_lib_verb(args, "plate_vias", vec![("name", s_or(args, "footprintName", ""))], 90)
}
pub fn set_params(args: &Value) -> Value {
let lib = match s(args, "libraryPath") {
Some(l) if !l.is_empty() => l,
_ => return json!({"success": false, "error": "libraryPath (.SchLib) is required",
"_hint": "params:'LCSC Part #=C123;Manufacturer=TI' footprint:'SOT-23' comment:'10k'"}),
};
let params = match args.get("params") {
Some(Value::Object(m)) => m.iter()
.map(|(k, v)| format!("{k}={}", v.as_str().map(str::to_string).unwrap_or(v.to_string())))
.collect::<Vec<_>>().join(";"),
Some(Value::String(p)) => p.clone(),
_ => String::new(),
};
let mut r = lib_call(args, "set_params",
vec![("name", s_or(args, "symbolName", "")), ("params", params),
("footprint", s_or(args, "footprint", "")), ("comment", s_or(args, "comment", ""))],
Some(&lib), 90);
if r.get("_hint").is_none() {
r["_hint"] = json!("Parameters set on the symbol (unsaved) — altium_save {filePath: libraryPath}. LCSC Part # / Manufacturer Part Number are the JLC/BOM keys.");
}
r
}
pub fn set_visibility(args: &Value) -> Value {
let vis = if args.get("visible").and_then(|b| b.as_bool()).unwrap_or(true) { "1" } else { "0" };
sch_lib_verb(args, "set_visibility",
vec![("name", s_or(args, "symbolName", "")), ("param", s_or(args, "param", "")), ("visible", vis.into())], 90)
}
pub fn read_symbol(args: &Value) -> Value {
sch_lib_verb(args, "read_symbol", vec![("name", s_or(args, "symbolName", ""))], 90)
}
pub fn clone_symbol(args: &Value) -> Value {
sch_lib_verb(args, "clone_symbol", vec![("from", s_or(args, "from", "")), ("to", s_or(args, "to", ""))], 90)
}
pub fn rename_symbol(args: &Value) -> Value {
sch_lib_verb(args, "rename_symbol", vec![("from", s_or(args, "from", "")), ("to", s_or(args, "to", ""))], 90)
}
pub fn rename_footprint(args: &Value) -> Value {
sch_lib_verb(args, "rename_footprint", vec![("from", s_or(args, "from", "")), ("to", s_or(args, "to", ""))], 90)
}
pub fn remove_symbol(args: &Value) -> Value {
sch_lib_verb(args, "remove_symbol", vec![("name", s_or(args, "symbolName", ""))], 60)
}
pub fn remove_footprint(args: &Value) -> Value {
sch_lib_verb(args, "remove_footprint", vec![("name", s_or(args, "footprintName", ""))], 90)
}
pub fn copy_footprint(args: &Value) -> Value {
sch_lib_verb(args, "copy_footprint", vec![("from", s_or(args, "from", "")), ("to", s_or(args, "to", ""))], 90)
}
pub fn cleanup_libs(args: &Value) -> Value {
sch_lib_verb(args, "cleanup_libs", vec![], 90)
}
pub fn xcopy_footprint(args: &Value) -> Value {
if let Some(g) = support::need_licensed() {
return g;
}
if let Some(p) = s(args, "toLib") {
if let Some(e) = support::must_open(&p, 60) { return e; }
}
let to = s(args, "to").or_else(|| s(args, "from")).unwrap_or_default();
agent::call("xcopy_footprint",
&[("fromLib", s_or(args, "fromLib", "")), ("from", s_or(args, "from", "")), ("to", to)],
None, timeout_of(args, 120))
}
pub fn swap_footprint(args: &Value) -> Value {
if let Some(g) = support::need_licensed() {
return g;
}
if let Some(p) = s(args, "pcbPath") {
if let Some(e) = support::must_open(&p, 60) { return e; }
}
let mut r = agent::call("swap_footprint",
&[("footprint", s_or(args, "footprintName", "")), ("assigns", s_or(args, "assigns", "")),
("libraryPath", s_or(args, "libraryPath", ""))],
None, timeout_of(args, 240));
r["windowEtiquette"] = json!("background");
r
}
/// The editor paints its OWN mode: Comp.DisplayMode changes data only (measured 2026-08-30,
/// frames identical). Drive the editor's process: Mode0, then Next × mode.
pub fn symbol_mode(args: &Value) -> Value {
if let Some(g) = support::need_licensed() {
return g;
}
if let Some(p) = s(args, "libraryPath") {
if let Some(e) = support::must_open(&p, 60) { return e; }
}
let mode = args.get("mode").and_then(|v| v.as_u64()).unwrap_or(0);
agent::call("run_process", &[("process", "Sch:ToggleComponentDisplayMode".into()), ("params", "Action=Mode0".into())], None, Duration::from_secs(30));
for _ in 0..mode {
agent::call("run_process", &[("process", "Sch:ToggleComponentDisplayMode".into()), ("params", "Action=Next".into())], None, Duration::from_secs(30));
}
let r = agent::call("symbol_mode", &[("mode", mode.to_string())], None, timeout_of(args, 60));
agent::call("run_process", &[("process", "Sch:Zoom".into()), ("params", "Action=All".into())], None, Duration::from_secs(30));
std::thread::sleep(Duration::from_millis(800));
with_shot(r)
}
pub fn install_library(args: &Value) -> Value {
let lib = match s(args, "libraryPath") {
Some(l) if !l.is_empty() => l,
_ => return json!({"success": false, "error": "libraryPath is required (.IntLib/.SchLib/.PcbLib/.DbLib)"}),
};
let mut r = lib_call(args, "install_library", vec![("path", lib)], None, 90);
if r.get("_hint").is_none() {
r["_hint"] = json!("Registered in Altium's Installed Libraries (IntegratedLibraryManager.InstallLibrary).");
}
r
}
pub fn compile_intlib(args: &Value) -> Value {
let p = match s(args, "libpkgPath") {
Some(l) if !l.is_empty() => l,
_ => return json!({"success": false, "error": "libpkgPath (.LibPkg) is required; output = destination dir"}),
};
lib_call(args, "compile_intlib", vec![("path", p), ("output", s_or(args, "output", ""))], None, 300)
}
pub fn extract_intlib(args: &Value) -> Value {
let p = match s(args, "intlibPath") {
Some(l) if !l.is_empty() => l,
_ => return json!({"success": false, "error": "intlibPath (.IntLib) is required"}),
};
let mut r = lib_call(args, "extract_intlib", vec![("path", p)], None, 300);
if r.get("_hint").is_none() {
r["_hint"] = json!("sources[] are the editable .SchLib/.PcbLib Altium extracted next to the IntLib (now open in the workspace).");
}
r
}
pub fn place_on_sch(args: &Value) -> Value {
if let Some(g) = support::need_licensed() {
return g;
}
let lib = match s(args, "libraryPath") {
Some(l) if !l.is_empty() => l,
_ => return json!({"success": false, "error": "libraryPath (.SchLib) required"}),
};
if let Some(e) = support::must_open(&lib, 60) { return e; }
let title = s_or(args, "title", ""); let number = s_or(args, "number", ""); let revision = s_or(args, "revision", "");
let drawn = s_or(args, "drawnBy", ""); let desc = s_or(args, "description", "");
let sch = s_or(args, "schPath", "");
if !sch.is_empty() {
ensure_parent_dir(&sch);
}
let mut r = with_shot(agent::call("place_on_sch",
&[("sch", sch.clone()), ("libref", s_or(args, "symbolName", "CH340G")),
("title", title.clone()), ("number", number.clone()), ("revision", revision.clone()),
("drawnBy", drawn.clone()), ("description", desc.clone())],
None, timeout_of(args, 120)));
let missing: Vec<&str> = [("title", &title), ("number", &number), ("revision", &revision), ("drawnBy", &drawn), ("description", &desc)]
.iter().filter(|(_, v)| v.is_empty()).map(|(k, _)| *k).collect();
let mut hints = vec![
"The sheet is sized for the part count (A4 up to two parts, B from three) and every part sits inside the drawing frame as U1..Un with its part number as the Comment.".to_string(),
"Prove it with altium_show_schematic {filePath: schPath}; after a re-run close the stale sheet first (altium_close_document {path}) or the capture shows the OLD document.".to_string()];
if missing.is_empty() {
hints.push("Title block filled (Title, DocumentNumber, Revision, DrawnBy) and the description placed as a text frame inside the drawing frame along the top; designators sit above each body, part numbers below.".into());
} else {
hints.push(format!("TITLE BLOCK INCOMPLETE: pass {} on this verb. The AI writes them: title = what the sheet is, number = the project or page id, revision, drawnBy = the AI thread on behalf of the user, description = two or three sentences from the datasheets or the wiki component page saying what the parts are and what the sheet demonstrates. An empty title block and '*' comments read as an unfinished design.", missing.join(", ")));
}
r["_hints"] = json!(hints);
mark_saved(&mut r, &sch);
r
}
/// Draw the chip-outline artwork (the chip-thumbnailer polylines adom-symbol baked into the KiCad
/// symbol) into a SchLib component, so the Altium symbol shows the same chip as KiCad and Fusion.
/// Reads `kicadSym` (a Windows path to the .kicad_sym on this box), takes the symbol's body rectangle
/// and every polyline, converts mm -> mil and hands them to the agent, which maps the KiCad body
/// rectangle onto the component's own rectangle.
pub fn add_symbol_art(args: &Value) -> Value {
if let Some(g) = support::need_licensed() {
return g;
}
let lib = match s(args, "libraryPath") {
Some(l) if !l.is_empty() => l,
_ => return json!({"success": false, "error": "libraryPath (.SchLib) required"}),
};
let name = s_or(args, "symbolName", "");
let ks = s_or(args, "kicadSym", "");
if name.is_empty() || ks.is_empty() {
return json!({"success": false, "error": "symbolName and kicadSym (Windows path to the .kicad_sym carrying the baked outline) are required"});
}
let text = match std::fs::read_to_string(&ks) {
Ok(t) => t,
Err(e) => return json!({"success": false, "error": format!("cannot read kicadSym {ks}: {e}"), "_hint": "send_files the .kicad_sym to this box first and pass its Windows path"}),
};
let (rect, polys) = kicad_symbol_art(&text, &name);
let rect = match rect {
Some(r) => r,
None => return json!({"success": false, "error": format!("no (rectangle ...) body found for symbol {name} in {ks}")}),
};
if polys.is_empty() {
return json!({"success": false, "error": format!("no polylines in symbol {name}: bake the outline first (adom-symbol create --outline-svg <mpn>-3d-outline-heavy.svg --outline-name none)")});
}
const MIL: f64 = 39.3700787;
let kr = format!("{:.0},{:.0},{:.0},{:.0}", rect.0 * MIL, rect.1 * MIL, rect.2 * MIL, rect.3 * MIL);
let payload: String = polys.iter().map(|pl| pl.iter().map(|(x, y)| format!("{:.0},{:.0}", x * MIL, y * MIL)).collect::<Vec<_>>().join(" ")).collect::<Vec<_>>().join("\n");
if let Some(e) = support::must_open(&lib, 60) { return e; }
let mut r = agent::call("add_symbol_art", &[("libref", name.clone()), ("kicadRect", kr), ("mode", s_or(args, "mode", "lines")), ("clear", if args.get("clear").and_then(|b| b.as_bool()) == Some(false) { "0".into() } else { "1".into() })], Some(&payload), timeout_of(args, 240));
r["_hints"] = json!([
format!("{} polylines drawn into {name} from the KiCad symbol's baked outline; altium_save {{filePath}} the SchLib, then altium_show_symbol to check the chip sits between the pin-name columns.", polys.len()),
"The outline is the reviewed chip artwork (side-etched MPN for optical tops); do not re-etch or re-generate it here, reuse the wiki page's artwork cache.",
"Replaces earlier art by default (clear:true removes the component's polylines and lines first); pass clear:false to stack. mode 'lines' (default) draws eLine segments, mode 'polyline' draws ISch_Polyline objects."]);
r
}
/// Read-only: a SchLib component's drawing (primitive counts, body rectangle, polyline bbox, models).
pub fn symbol_prims(args: &Value) -> Value {
if let Some(g) = support::need_licensed() {
return g;
}
let lib = match s(args, "libraryPath") {
Some(l) if !l.is_empty() => l,
_ => return json!({"success": false, "error": "libraryPath (.SchLib) required"}),
};
if let Some(e) = support::must_open(&lib, 60) { return e; }
agent::call("symbol_prims", &[("libref", s_or(args, "symbolName", "")), ("models", if args.get("models").and_then(|b| b.as_bool()) == Some(true) { "1".into() } else { "0".into() })], None, timeout_of(args, 60))
}
/// Replace every footprint model on a SchLib component with one PCBLIB model.
pub fn set_models(args: &Value) -> Value {
if let Some(g) = support::need_licensed() {
return g;
}
let lib = match s(args, "libraryPath") {
Some(l) if !l.is_empty() => l,
_ => return json!({"success": false, "error": "libraryPath (.SchLib) required"}),
};
if let Some(e) = support::must_open(&lib, 60) { return e; }
let mut r = agent::call("set_models", &[("libref", s_or(args, "symbolName", "")), ("footprint", s_or(args, "footprint", ""))], None, timeout_of(args, 60));
r["_hints"] = json!(["Replaces ALL models (set_params footprint and add_models append). altium_save the SchLib afterwards; altium_symbol_prims shows the models list."]);
r
}
/// (body rectangle in mm as (x0,y0,x1,y1), polylines in mm) for one symbol of a .kicad_sym.
fn kicad_symbol_art(text: &str, name: &str) -> (Option<(f64, f64, f64, f64)>, Vec<Vec<(f64, f64)>>) {
let head = format!("(symbol \"{name}\"");
let start = match text.find(&head) { Some(i) => i, None => return (None, vec![]) };
// the symbol block ends where the paren depth returns to zero
let bytes = text.as_bytes(); let mut depth = 0i32; let mut end = start;
for (i, &b) in bytes.iter().enumerate().skip(start) {
if b == b'(' { depth += 1; } else if b == b')' { depth -= 1; if depth == 0 { end = i + 1; break; } }
}
let blk = &text[start..end];
let num = |s: &str| s.trim().parse::<f64>().ok();
let mut rect = None;
if let Some(i) = blk.find("(rectangle (start ") {
let rest = &blk[i + "(rectangle (start ".len()..];
let mut it = rest.split(|c: char| c == ' ' || c == ')' || c == '(').filter(|w| !w.is_empty());
let x0 = it.next().and_then(num); let y0 = it.next().and_then(num);
let _end = it.next(); let x1 = it.next().and_then(num); let y1 = it.next().and_then(num);
if let (Some(a), Some(b), Some(c), Some(d)) = (x0, y0, x1, y1) { rect = Some((a, b, c, d)); }
}
let mut polys = vec![];
let mut pos = 0;
while let Some(i) = blk[pos..].find("(polyline") {
let s0 = pos + i; let mut d = 0i32; let mut e = s0;
for (k, &b) in blk.as_bytes().iter().enumerate().skip(s0) {
if b == b'(' { d += 1; } else if b == b')' { d -= 1; if d == 0 { e = k + 1; break; } }
}
let pb = &blk[s0..e];
let mut pts = vec![]; let mut q = 0;
while let Some(j) = pb[q..].find("(xy ") {
let a = q + j + 4; let z = pb[a..].find(')').map(|k| a + k).unwrap_or(pb.len());
let mut w = pb[a..z].split_whitespace();
if let (Some(x), Some(y)) = (w.next().and_then(num), w.next().and_then(num)) { pts.push((x, y)); }
q = z;
}
if pts.len() >= 2 { polys.push(pts); }
pos = e;
}
(rect, polys)
}
pub fn place_on_pcb(args: &Value) -> Value {
if let Some(g) = support::need_licensed() {
return g;
}
let lib = match s(args, "libraryPath") {
Some(l) if !l.is_empty() => l,
_ => return json!({"success": false, "error": "libraryPath (.PcbLib) required"}),
};
if let Some(e) = support::must_open(&lib, 60) { return e; }
with_shot(agent::call("place_on_pcb",
&[("pcb", s_or(args, "pcbPath", "")), ("footprint", s_or(args, "footprintName", ""))],
None, timeout_of(args, 90)))
}
/// Make sure a document's target folder exists before the agent saves into it. Altium's
/// DoFileSave into a missing folder fails silently: test_board answered with the path while no
/// file was written, and the next call could not find the board to append to.
fn ensure_parent_dir(path: &str) {
if let Some(parent) = std::path::Path::new(path).parent() {
if !parent.as_os_str().is_empty() {
let _ = std::fs::create_dir_all(parent);
}
}
}
/// After a verb that saves to `path`: did the file land? The bridge runs on the same machine as
/// Altium, so it checks the disk itself instead of trusting the agent's answer.
fn mark_saved(r: &mut Value, path: &str) {
if path.is_empty() {
return;
}
let saved = std::path::Path::new(path).is_file();
r["saved"] = json!(saved);
if !saved && r.get("success").and_then(|b| b.as_bool()) == Some(true) {
r["success"] = json!(false);
r["errorCode"] = json!("not_saved");
r["error"] = json!(format!("the document was built but {path} was not written; check the folder is writable and the name is a valid file name"));
}
}
pub fn test_board(args: &Value) -> Value {
if let Some(g) = support::need_licensed() {
return g;
}
if let Some(p) = s(args, "libraryPath") {
if let Some(e) = support::must_open(&p, 60) { return e; }
}
let pcb = s_or(args, "pcbPath", "");
if !pcb.is_empty() {
ensure_parent_dir(&pcb);
}
let mut r = agent::call("test_board",
&[("footprint", s_or(args, "footprintName", "")), ("wMm", s_or(args, "wMm", "15")),
("hMm", s_or(args, "hMm", "15")), ("pcb", pcb.clone()),
("assigns", s_or(args, "assigns", "")), ("stubs", s_or(args, "stubs", ""))],
None, timeout_of(args, 240));
mark_saved(&mut r, &pcb);
r["windowEtiquette"] = json!("background");
r["_hints"] = json!([
"footprintName takes a ';' list: every part lands on ONE board, spread evenly along X inside the wMm x hMm outline; pass pcbPath so the board is saved and altium_show_3d_board {filePath} can prove it.",
"APPEND (issue 11): when pcbPath names an existing .PcbDoc the parts are ADDED to it - placed to the right of the current parts, designators continuing from the existing count - and the outline/layers are left alone (wMm/hMm ignored). The reply carries appended:true and existingParts.",
"Give a new board an outline that fits the parts (roughly 12 mm of width per 6 mm part plus margins) and check the 3D frame: bodies come from the PcbLib (altium_add_body), a missing body means the footprint has none."]);
r
}
/// Sanitise a value for the agent's model spec: models split on ';', fields on '|' — an unescaped
/// separator in a DESCRIPTION silently truncates the spec.
fn spec_safe(v: &str) -> String {
v.replace(';', ",").replace('|', "/")
}
fn params_spec(params: &Value) -> String {
match params {
Value::Object(m) => m.iter()
.map(|(k, v)| format!("{k}={}", v.as_str().map(str::to_string).unwrap_or(v.to_string()).replace(';', ",")))
.collect::<Vec<_>>().join(";"),
_ => String::new(),
}
}
/// Attach N PCB footprint models (Altium's footprint-variant mechanism) + parameters to a SchLib
/// component. NOT idempotent — each call APPENDS another implementation (known Altium defect);
/// repair = remove_symbol + re-import.
pub fn add_models(args: &Value) -> Value {
if let Some(g) = support::need_licensed() {
return g;
}
let lib = s(args, "libraryPath").unwrap_or_default();
let name = s(args, "symbolName").unwrap_or_default();
if lib.is_empty() || name.is_empty() {
return json!({"success": false, "error": "libraryPath (.SchLib) and symbolName are required"});
}
let models: Vec<Value> = args.get("models").and_then(|m| m.as_array()).cloned().unwrap_or_default();
let spec = models.iter().map(|m| format!("{}|{}|{}",
m.get("name").and_then(|v| v.as_str()).unwrap_or(""),
m.get("pcblib").and_then(|v| v.as_str()).unwrap_or(""),
spec_safe(m.get("description").and_then(|v| v.as_str()).unwrap_or(""))))
.collect::<Vec<_>>().join(";");
let pspec = params_spec(args.get("params").unwrap_or(&Value::Null));
let mut r = agent::call("add_models",
&[("libraryPath", lib), ("name", name), ("models", spec), ("params", pspec),
("comment", s_or(args, "comment", "")), ("description", s_or(args, "description", ""))],
None, timeout_of(args, 120));
if r.get("success").and_then(|b| b.as_bool()) == Some(true) {
r["shot"] = shot::shot_of_main();
} else {
r["shot"] = Value::Null;
}
r["_aiHint"] = json!("add_models APPENDS an implementation each call — calling it twice leaves duplicate footprint models on one symbol. Repair = altium_remove_symbol + re-import. read_symbol does not report models (models:null is a reporting gap, not a missing link).");
r
}
/// Barrett's fat verb, on the fast lane: one call imports a whole part.
/// Args: schLib, pcbLib, symbolSpec (symbol_modes spec path) OR pins, footprintSpec
/// (kicad2altium spec path), step, params{}, comment, description.
pub fn import_part(args: &Value) -> Value {
if let Some(g) = support::need_licensed() {
return g;
}
let mut steps: Vec<Value> = Vec::new();
let mut fp_name: Option<String> = None;
let pcb_lib = s(args, "pcbLib").unwrap_or_default();
let sch_lib = s(args, "schLib").unwrap_or_default();
// Given nothing to import, this used to run zero steps and answer success:true (found by the
// verb battery, 2026-09-20). Doing nothing is not success.
let has_work = ["footprintSpec", "symbolSpec", "step", "params"].iter()
.any(|k| args.get(*k).map(|v| !v.is_null() && v.as_str().map(|t| !t.is_empty()).unwrap_or(true)).unwrap_or(false));
if !has_work {
return json!({"success": false, "errorCode": "nothing_to_import",
"error": "import_part was given nothing to import: pass footprintSpec and/or symbolSpec (with pcbLib / schLib), optionally step and params",
"_hint": "See the altium-part-import skill for the spec format; altium_create_footprint / altium_create_symbol build a single item."});
}
if let Some(spec) = s(args, "footprintSpec").filter(|v| !v.is_empty()) {
let r = agent::call("build_footprint", &[("libraryPath", pcb_lib.clone()), ("spec", spec)], None, Duration::from_secs(240));
if r.get("success").and_then(|b| b.as_bool()) != Some(true) {
let mut out = r;
out["success"] = json!(false);
out["failedAt"] = json!("build_footprint");
return out;
}
fp_name = r.get("data").and_then(|d| d.get("footprint")).and_then(|f| f.as_str()).map(str::to_string);
steps.push(json!({"build_footprint": fp_name}));
}
if let (Some(step), Some(fp)) = (s(args, "step").filter(|v| !v.is_empty()), fp_name.clone()) {
// keep=0: a part imported again REPLACES its 3D body (it used to stack a second one on the
// footprint: AMPLH5030S, 2026-10-16), the way build_symbol_modes replaces its symbol.
let r = agent::call("add_body",
&[("libraryPath", pcb_lib.clone()), ("name", fp.clone()), ("step", step),
("bw_mil", "100".into()), ("bh_mil", "100".into()), ("h_mil", "40".into()), ("so_mil", "0".into()),
("keep", "0".into())],
None, Duration::from_secs(180));
let d = r.get("data").cloned().unwrap_or(json!({}));
steps.push(json!({"add_body": r.get("success").and_then(|b| b.as_bool()).unwrap_or(false),
"replacedBodies": d.get("replacedBodies"), "bodiesNow": d.get("bodiesNow"),
"sameNameFootprints": d.get("sameNameFootprints")}));
agent::call("normalize_bodies", &[("name", fp)], None, Duration::from_secs(90));
}
let mut sym_name = s(args, "symbolName");
if let Some(spec) = s(args, "symbolSpec").filter(|v| !v.is_empty()) {
support::agent_open(&sch_lib, 60);
let r = agent::call("build_symbol_modes", &[("libraryPath", sch_lib.clone()), ("spec", spec)], None, Duration::from_secs(240));
if r.get("success").and_then(|b| b.as_bool()) != Some(true) {
let mut out = r;
out["success"] = json!(false);
out["failedAt"] = json!("build_symbol_modes");
out["steps"] = json!(steps);
return out;
}
sym_name = r.get("data").and_then(|d| d.get("libref")).and_then(|f| f.as_str()).map(str::to_string);
steps.push(json!({"build_symbol_modes": sym_name}));
}
if let (Some(sym), Some(fp)) = (sym_name.clone(), fp_name.clone()) {
let desc_raw = s_or(args, "description", "");
let desc: String = spec_safe(&desc_raw).chars().take(80).collect();
let r = agent::call("add_models",
&[("libraryPath", sch_lib.clone()), ("name", sym.clone()),
("models", format!("{fp}|{pcb_lib}|{desc}")),
("params", params_spec(args.get("params").unwrap_or(&Value::Null))),
("comment", s(args, "comment").unwrap_or_else(|| sym.clone())),
("description", desc_raw)],
None, Duration::from_secs(120));
let d = r.get("data").cloned().unwrap_or(json!({}));
let ok = r.get("success").and_then(|b| b.as_bool()) == Some(true);
steps.push(json!({"add_models": ok, "models": d.get("models"), "params": d.get("params")}));
let models_n = d.get("models").and_then(|m| m.as_i64()).unwrap_or(0);
if ok && models_n == 0 {
// attaching ZERO models is a silent failure: the symbol places with no footprint
let mut out = r;
out["success"] = json!(false);
out["failedAt"] = json!("add_models");
out["steps"] = json!(steps);
out["error"] = json!("add_models attached 0 footprint models — the symbol would place with no footprint. Check the model spec separators (';' between models, '|' between name|pcblib|description).");
return out;
}
}
for lib in [&pcb_lib, &sch_lib] {
if !lib.is_empty() {
support::agent_open(lib, 60);
agent::call("run_process",
&[("process", "WorkspaceManager:SaveObject".into()), ("params", "ObjectKind=FocusedDocument".into())],
None, Duration::from_secs(60));
}
}
json!({"success": true, "symbol": sym_name, "footprint": fp_name, "steps": steps,
"_hint": "One-call part import (Barrett's import_part on the resident lane). Importing a part again REPLACES its symbol and its footprint's 3D body (steps[].replacedBodies says how many old bodies went; bodiesNow should be 1). Verify with altium_read_symbol + altium_pcb_report; placed parts pick the change up with PCB:UpdateFootprints."})
}
// ---- install_library_bundle: the no-AI web-control lane ----------------------
fn sanitise_lib_name(name: &str) -> String {
name.chars().map(|c| if c.is_ascii_alphanumeric() || "_.-".contains(c) { c } else { '_' }).collect()
}
fn valid_lib_name(name: &str) -> bool {
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_ascii_alphanumeric() => {}
_ => return false,
}
name.len() <= 64 && name.chars().all(|c| c.is_ascii_alphanumeric() || " ._-".contains(c))
}
/// Unpack a staged zip (<Lib>.SchLib + <Lib>.PcbLib [+ .IntLib], regardless of a wrapping
/// folder), install into the user's Installed Libraries, optionally return a proof frame.
/// Hardened for untrusted zips: size caps, zip-slip refusal.
/// Per-step wall clock of one bundle install, so a slow run names its step (the
/// 2026-09-08 report: an install outlived ab's 60 s budget with nothing saying
/// where the time went).
struct Phases { last: Instant, items: Vec<(String, u64)> }
impl Phases {
fn start() -> Self { Phases { last: Instant::now(), items: Vec::new() } }
fn mark(&mut self, name: &str) {
self.items.push((name.to_string(), self.last.elapsed().as_millis() as u64));
self.last = Instant::now();
}
fn to_json(&self) -> Value {
let mut m = serde_json::Map::new();
for (k, v) in &self.items { m.insert(k.clone(), json!(v)); }
Value::Object(m)
}
}
/// The last bundle install's receipt, proof frame included. ab drops a verb that
/// outlives its budget while this bridge, which serves one verb at a time,
/// finishes it anyway; a client that got "timed out" polls altium_status (the
/// light summary) until lastInstall carries its installId, then reads the full
/// receipt back through altium_install_receipt.
static LAST_INSTALL: Mutex<Option<Value>> = Mutex::new(None);
fn remember_install(receipt: &Value) {
if let Ok(mut g) = LAST_INSTALL.lock() { *g = Some(receipt.clone()); }
}
pub fn last_install() -> Value {
LAST_INSTALL.lock().ok().and_then(|g| g.clone()).unwrap_or(Value::Null)
}
/// The receipt without its heavy fields, for the status verb every poll reads.
pub fn last_install_summary() -> Value {
let mut v = last_install();
if let Some(o) = v.as_object_mut() {
for k in ["thumbnailDataUrl", "shot", "components", "installed"] { o.remove(k); }
}
v
}
pub fn install_receipt(_args: &Value) -> Value {
let r = last_install();
if r.is_null() {
return json!({"success": true, "receipt": Value::Null,
"_hint": "No bundle install has run in this bridge process yet (the receipt lives in memory; a bridge restart clears it)."});
}
json!({"success": true, "receipt": r})
}
pub fn install_library_bundle(args: &Value) -> Value {
let t0 = Instant::now();
let mut phases = Phases::start();
let mut out = install_library_bundle_inner(args, &mut phases);
if let Some(o) = out.as_object_mut() {
o.insert("phasesMs".into(), phases.to_json());
o.insert("totalMs".into(), json!(t0.elapsed().as_millis() as u64));
o.insert("atMs".into(), json!((support::now() * 1000.0) as u64));
if let Some(id) = s(args, "installId") { o.insert("installId".into(), json!(id)); }
if !o.contains_key("library") { o.insert("library".into(), json!(s_or(args, "libraryName", ""))); }
}
remember_install(&out);
out
}
fn install_library_bundle_inner(args: &Value, ph: &mut Phases) -> Value {
// One-click parity with the KiCad/Fusion lanes (Colby, adom/altium-bridge#5): from a cold
// state, launch Altium first (measured cold start on the taskbar bar), then register. The
// resident agent bootstraps on the first agent call. Safe to ship now that the close
// watchdog (#4) exits the agent on a user-initiated close.
let mut launched = Value::Null;
if crate::instance::licence_state().get("running").and_then(|b| b.as_bool()) != Some(true) {
let lr = crate::misc::launch(&json!({"waitSec": 240}));
if lr.get("success").and_then(|b| b.as_bool()) != Some(true) {
let mut out = lr;
out["failedAt"] = json!("launch");
out["_hint"] = json!("Altium was closed and could not be launched for the install — see error; altium_launch to retry.");
return out;
}
launched = json!({"coldStartSec": lr.get("startedInSec"), "note": "Altium was closed; launched it for the install"});
// RE-RESOLVE the instance we just launched. Dispatch resolved the instance ONCE at the top
// of the verb, when Altium was not running, so no instance was selected — and the agent
// would then bootstrap into the LEGACY workspace instead of the per-instance one. That
// split-brain is the #6 blind spot: a loop in the legacy workspace is invisible to the
// per-instance status and teardown, and the app closes over a live loop. Select the running
// instance now so the agent workspace is keyed to it and every status/teardown path sees it.
crate::instance::invalidate_snapshot();
if let Ok(inst) = crate::instance::resolve(None) {
crate::instance::select(Some(inst));
}
}
ph.mark("launch");
if let Some(g) = support::need_licensed() {
return g;
}
ph.mark("licence");
let zp = match s(args, "zipPath") {
Some(z) if !z.is_empty() => z,
_ => return json!({"success": false, "error": "zipPath is required (stage the bundle with write_file / write_file_chunk first)"}),
};
let name = s_or(args, "libraryName", "");
if !name.is_empty() && !valid_lib_name(&name) {
return json!({"success": false, "error": "libraryName must match ^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$"});
}
let meta = match std::fs::metadata(&zp) {
Ok(m) if m.is_file() => m,
_ => return json!({"success": false, "failedAt": "unpack", "error": format!("staged zip not found: {zp}")}),
};
if meta.len() > 268_435_456 {
return json!({"success": false, "failedAt": "unpack", "error": "zip over 256MB"});
}
let public = std::env::var("PUBLIC").unwrap_or_else(|_| r"C:\Users\Public".into());
let stem = std::path::Path::new(&zp).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
let dest = std::path::Path::new(&public).join("AdomAltiumLibs")
.join(sanitise_lib_name(if name.is_empty() { &stem } else { &name }));
let _ = std::fs::remove_dir_all(&dest);
if let Err(e) = std::fs::create_dir_all(&dest) {
return json!({"success": false, "failedAt": "unpack", "error": format!("cannot create {}: {e}", dest.display())});
}
let file = match std::fs::File::open(&zp) {
Ok(f) => f,
Err(e) => return json!({"success": false, "failedAt": "unpack", "error": format!("{e}")}),
};
let mut z = match zip::ZipArchive::new(file) {
Ok(z) => z,
Err(_) => return json!({"success": false, "failedAt": "unpack", "error": "not a valid zip"}),
};
let mut total: u64 = 0;
for i in 0..z.len() {
let mut entry = match z.by_index(i) {
Ok(e) => e,
Err(_) => return json!({"success": false, "failedAt": "unpack", "error": "not a valid zip"}),
};
total += entry.size();
if total > 536_870_912 {
return json!({"success": false, "failedAt": "unpack", "error": "unpacked over 512MB"});
}
let raw = entry.name().replace('\\', "/");
let parts: Vec<&str> = raw.split('/').collect();
if raw.starts_with('/') || parts.iter().any(|p| *p == "..") || (raw.len() > 1 && raw.as_bytes()[1] == b':') {
return json!({"success": false, "failedAt": "unpack", "error": format!("zip slip: {raw}")});
}
let target = parts.iter().fold(dest.clone(), |acc, p| acc.join(p));
if entry.is_dir() {
let _ = std::fs::create_dir_all(&target);
continue;
}
if let Some(parent) = target.parent() {
let _ = std::fs::create_dir_all(parent);
}
match std::fs::File::create(&target) {
Ok(mut out) => {
if std::io::copy(&mut entry, &mut out).is_err() {
return json!({"success": false, "failedAt": "unpack", "error": format!("cannot write {raw}")});
}
}
Err(e) => return json!({"success": false, "failedAt": "unpack", "error": format!("cannot write {raw}: {e}")}),
}
}
// Flatten a lone wrapper folder: the wiki's export zip wraps everything in `<Lib>/` (so the
// plain download unpacks tidily), and preserving it under our own `<Lib>/` made the confusing
// `UI\UI\` double nesting Colby hit. One top-level dir and nothing else = hoist its contents.
if let Ok(rd) = std::fs::read_dir(&dest) {
let entries: Vec<_> = rd.flatten().collect();
if entries.len() == 1 && entries[0].path().is_dir() {
let wrapper = entries[0].path();
if let Ok(inner) = std::fs::read_dir(&wrapper) {
for e in inner.flatten() {
let _ = std::fs::rename(e.path(), dest.join(e.file_name()));
}
}
let _ = std::fs::remove_dir(&wrapper);
}
}
let mut schlibs = Vec::new();
let mut pcblibs = Vec::new();
let mut intlibs = Vec::new();
let mut stack = vec![dest.clone()];
while let Some(d) = stack.pop() {
if let Ok(rd) = std::fs::read_dir(&d) {
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
stack.push(p);
continue;
}
let lower = p.to_string_lossy().to_lowercase();
if lower.ends_with(".schlib") { schlibs.push(p); }
else if lower.ends_with(".pcblib") { pcblibs.push(p); }
else if lower.ends_with(".intlib") { intlibs.push(p); }
}
}
}
if schlibs.is_empty() && pcblibs.is_empty() && intlibs.is_empty() {
return json!({"success": false, "failedAt": "unpack", "error": "bundle holds no .SchLib/.PcbLib/.IntLib"});
}
let to_install: Vec<&std::path::PathBuf> = if !intlibs.is_empty() {
intlibs.iter().collect()
} else {
schlibs.iter().chain(pcblibs.iter()).collect()
};
ph.mark("unpack");
let mut installed = Vec::new();
for lib in to_install {
let winpath = lib.to_string_lossy().replace('/', "\\");
let ir = agent::call("install_library", &[("path", winpath)], None, Duration::from_secs(120));
installed.push(json!({"lib": lib.file_name().map(|n| n.to_string_lossy().to_string()),
"ok": ir.get("success").and_then(|b| b.as_bool()).unwrap_or(false)}));
}
ph.mark("register");
let mut out = json!({"success": true, "launched": launched,
"library": if name.is_empty() { stem } else { name },
"symbolFiles": schlibs.len(), "footprintFiles": pcblibs.len(), "intlibs": intlibs.len(),
// legacy names kept one release for the wiki page's current parser; they are FILE counts
"symbols": schlibs.len(), "footprints": pcblibs.len(),
"installed": installed, "shown": false});
// ---- the #7 show-and-prove: the user who clicked "Export into Altium" must LAND on their
// library, not the Home Page, with the receipt carrying the proof frame and an honest
// `shown` — on the auto-launch path too, where 0.2.8 always skipped it. Registration via
// IntegratedLibraryManager takes effect live (measured: the Components panel served the
// installed library in the same session), so no restart story.
if !schlibs.is_empty() || !pcblibs.is_empty() {
let show_path = schlibs.first().or_else(|| pcblibs.first()).unwrap();
let kind = if schlibs.is_empty() { "pcb" } else { "sch" };
let o = support::agent_open(&show_path.to_string_lossy().replace('/', "\\"), 60);
let opened = o.get("success").and_then(|b| b.as_bool()) == Some(true);
ph.mark("open");
if opened {
let lst = agent::call("lib_list", &[("kind", kind.into()), ("limit", "200".into())], None, Duration::from_secs(30));
if let Some(n) = lst.get("data").and_then(|d| d.get("count")) {
out[if kind == "sch" { "symbolCount" } else { "footprintCount" }] = n.clone();
out["components"] = lst.get("data").and_then(|d| d.get("names")).cloned().unwrap_or(Value::Null);
}
ph.mark("list");
// land the user on the library LIST, not just the first symbol's canvas: the panel
// tab wants the UIA select action, not a click (a click does not switch, measured)
if let Some(hw) = crate::instance::licence_state().get("hwnd").and_then(|h| h.as_i64()) {
let panel = if kind == "sch" { "SCH Library" } else { "PCB Library" };
let sel = ab::call("desktop_ui_select", json!({"hwnd": hw, "name": panel,
"reason": "land the user on their freshly installed library list (adom/altium-bridge#7)"}), &[], 10);
out["panelSelected"] = json!(sel.get("success").and_then(|b| b.as_bool()).unwrap_or(false));
}
ph.mark("panel");
let frame = shot::shot_of_main();
ph.mark("capture");
if let Some(img) = frame.get("image").and_then(|i| i.as_str()) {
out["thumbnailDataUrl"] = json!(format!("data:image/png;base64,{img}"));
out["shot"] = frame.clone();
out["shown"] = json!(true);
}
} else {
out["shownError"] = o.get("error").cloned().unwrap_or(Value::Null);
}
}
// ---- the #6 discipline: the one-click lane must never leave a resident script for a later
// close to wedge on. Every live loop is stopped before the reply goes out; the next verb
// re-bootstraps in ~8s. The library stays open for the user; the ENGINE goes idle.
let teardown = crate::lifecycle::stop_all_live();
ph.mark("agentStop");
out["agentStopped"] = json!(teardown.get("success").and_then(|b| b.as_bool()).unwrap_or(false));
out["_hint"] = json!("Registered in Installed Libraries (live, no restart needed) and opened in Altium when shown:true - the SCH/PCB Library panel lists every part. The resident agent was stopped afterward so a user-initiated Altium close finds an idle engine (adom/altium-bridge#6).");
out
}
/// Every library in Altium's Installed Libraries list.
pub fn list_installed_libraries(args: &Value) -> Value {
if let Some(g) = support::need_licensed() {
return g;
}
let r = agent::call("list_installed_libraries", &[], None, timeout_of(args, 30));
let d = r.get("data").cloned().unwrap_or(Value::Null);
if d.get("ok").and_then(|b| b.as_bool()) != Some(true) {
return r;
}
json!({"success": true, "count": d.get("count"), "libraries": d.get("libraries"),
"_hint": "altium_uninstall_library {libraryName | libraryPath} removes entries; the files are never touched."})
}
fn file_stem_lower(p: &str) -> String {
let base = p.rsplit(['\\', '/']).next().unwrap_or(p);
base.rsplit_once('.').map(|(s, _)| s).unwrap_or(base).to_lowercase()
}
/// Unregister installed libraries: the inverse of install_library / install_library_bundle.
/// libraryPath matches one registered path exactly (case-insensitive); libraryName matches every
/// registered library whose file stem equals it (X.SchLib and X.PcbLib together). Library files
/// are never touched. Nothing matching is an answer (notInstalled:true), not a failure, so a
/// cleanup can run twice.
pub fn uninstall_library(args: &Value) -> Value {
if let Some(g) = support::need_licensed() {
return g;
}
let by_path = s(args, "libraryPath").filter(|v| !v.is_empty());
let by_name = s(args, "libraryName").filter(|v| !v.is_empty());
if by_path.is_none() && by_name.is_none() {
return json!({"success": false, "errorCode": "library_required",
"error": "pass libraryPath (an exact registered path) or libraryName (a file stem, matches both the SchLib and the PcbLib)",
"_hint": "altium_list_installed_libraries shows what is registered"});
}
let listed = agent::call("list_installed_libraries", &[], None, Duration::from_secs(30));
let d = listed.get("data").cloned().unwrap_or(Value::Null);
if d.get("ok").and_then(|b| b.as_bool()) != Some(true) {
return listed;
}
let installed: Vec<String> = d.get("libraries").and_then(|a| a.as_array())
.map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();
let norm = |p: &str| p.replace('/', "\\").to_lowercase();
let matches: Vec<String> = installed.iter().filter(|p| {
by_path.as_deref().map(|q| norm(p) == norm(q)).unwrap_or(false)
|| by_name.as_deref().map(|n| file_stem_lower(p) == n.to_lowercase()).unwrap_or(false)
}).cloned().collect();
if matches.is_empty() {
return json!({"success": true, "removed": [], "notInstalled": true, "remaining": installed.len(),
"_hint": "nothing registered matched; altium_list_installed_libraries shows what is"});
}
if args.get("dryRun").and_then(|b| b.as_bool()) == Some(true) {
return json!({"success": true, "dryRun": true, "wouldRemove": matches, "remaining": installed.len()});
}
for p in &matches {
if p.contains(';') {
return json!({"success": false, "errorCode": "unsupported_path", "error": format!("a ';' in {p} cannot be passed to the agent")});
}
}
let r = agent::call("uninstall_library", &[("paths", matches.join(";"))], None, timeout_of(args, 60));
let d = r.get("data").cloned().unwrap_or(Value::Null);
if d.get("ok").and_then(|b| b.as_bool()) != Some(true) {
return r;
}
json!({"success": true, "removed": d.get("removed"), "closedDocuments": d.get("closedDocuments"),
"remaining": d.get("remaining"), "windowEtiquette": "background",
"_hint": "Removed from Installed Libraries now. Close Altium with a plain altium_close afterwards: a close that DISCARDS changes (discardChanges:true) can drop library-registration changes, so the removal would come back on the next launch."})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn install_receipt_is_remembered_and_the_status_summary_stays_light() {
remember_install(&json!({"success": true, "library": "L", "installId": "L-1", "thumbnailDataUrl": "data:x",
"shot": {"image": "y"}, "components": ["a"], "installed": [{"lib": "L.SchLib", "ok": true}], "shown": true}));
assert_eq!(last_install()["installId"], "L-1");
assert_eq!(install_receipt(&json!({}))["receipt"]["thumbnailDataUrl"], "data:x");
let sum = last_install_summary();
assert_eq!(sum["shown"], true);
assert_eq!(sum["installId"], "L-1");
for k in ["thumbnailDataUrl", "shot", "components", "installed"] { assert!(sum.get(k).is_none(), "{k} stripped"); }
}
#[test]
fn phases_record_each_step_in_order() {
let mut p = Phases::start();
p.mark("launch");
p.mark("unpack");
let j = p.to_json();
assert!(j.get("launch").is_some() && j.get("unpack").is_some());
assert_eq!(p.items[0].0, "launch");
assert_eq!(p.items[1].0, "unpack");
}
#[test]
fn add_body_replaces_unless_keep_existing_is_set() {
assert!(!keep_existing(&json!({})));
assert!(!keep_existing(&json!({"keepExisting": false})));
assert!(!keep_existing(&json!({"keepExisting": "no"})));
assert!(keep_existing(&json!({"keepExisting": true})));
assert!(keep_existing(&json!({"keepExisting": "true"})));
assert!(keep_existing(&json!({"keepExisting": 1})));
}
#[test]
fn add_body_hint_reports_replacement_and_flags_stacking() {
let h = body_hint(&json!({"footprint": "AMPLH5030S", "replacedBodies": 1, "bodiesNow": 1, "sameNameFootprints": 1}), false);
assert!(h.contains("Replaced 1 existing 3D body on AMPLH5030S"), "{h}");
assert!(!h.contains("WARNING"), "{h}");
let h = body_hint(&json!({"footprint": "F", "replacedBodies": 0, "bodiesNow": 1, "sameNameFootprints": 1}), false);
assert!(h.contains("had no 3D body") && !h.contains("WARNING"), "{h}");
// the replace did not take: two bodies remain
let h = body_hint(&json!({"footprint": "F", "replacedBodies": 0, "bodiesNow": 2, "sameNameFootprints": 1}), false);
assert!(h.contains("still reads 2 bodies"), "{h}");
// a deliberate second body is not a warning
let h = body_hint(&json!({"footprint": "F", "bodiesBefore": 1, "bodiesNow": 2, "sameNameFootprints": 1}), true);
assert!(h.contains("ADDED") && !h.contains("WARNING"), "{h}");
let h = body_hint(&json!({"footprint": "F", "replacedBodies": 1, "bodiesNow": 1, "sameNameFootprints": 2}), false);
assert!(h.contains("2 footprints in this library are named F"), "{h}");
}
#[test]
fn model_spec_separators_are_sanitised() {
assert_eq!(spec_safe("a;b|c"), "a,b/c");
assert_eq!(params_spec(&json!({"MPN": "X;1", "Val": "10k"})), "MPN=X,1;Val=10k");
}
#[test]
fn uninstall_matches_by_file_stem() {
assert_eq!(file_stem_lower(r"C:\Libs\Adom_ToF.SchLib"), "adom_tof");
assert_eq!(file_stem_lower("C:/Libs/Adom_ToF.PcbLib"), "adom_tof");
assert_eq!(file_stem_lower(r"C:\Libs\noext"), "noext");
}
#[test]
fn library_name_validation_matches_python() {
assert!(valid_lib_name("UTA Longhorn Racing"));
assert!(valid_lib_name("Lib_1.2-x"));
assert!(!valid_lib_name(" leading-space"));
assert!(!valid_lib_name("bad/slash"));
assert!(!valid_lib_name(""));
assert_eq!(sanitise_lib_name("a b/c"), "a_b_c");
}
}