← Commit history
README.md+56−32
@@ -1,52 +1,76 @@ # adom-desktop-altium-bridge -Adom Desktop bridge for Altium Designer — add LCSC-tagged parts to file-based libraries+Drive **Altium Designer** on a Windows desktop from a container — build and edit file-based+libraries, and generate batch outputs from an OutJob. -![Screenshot of adom-desktop-altium-bridge](docs/screenshot.png)+Altium ships no headless mode and no CLI. This bridge forwards work into the running GUI as+DelphiScript via `X2.EXE`, exposed as ordinary [Adom Desktop](https://wiki.adom.inc/adom-desktop)+verbs. -> Replace `docs/screenshot.png` with a real screenshot before publishing.-> The pre-publish lint nags about apps/skills without inline images-> because visual context dramatically improves discoverability.+![Adom Altium Bridge](docs/hero.png)  ## Install -```-adom-wiki pkg install adom-desktop-altium-bridge+```bash+adom-wiki pkg install barrett-land/adom-desktop-altium-bridge   # the skill, in the container+adom-desktop bridge_install '{"manifestUrl":"https://wiki.adom.inc/download/barrett-land/adom-desktop-altium-bridge/latest/altium-bridge-manifest.json"}'+adom-desktop --ai-thread "<your thread>" altium_ping '{}' ``` -## Usage+## What it can do -```-adom-desktop-altium-bridge-```+| Area | Verbs |+|---|---|+| Host-side (no Altium round-trip) | `altium_ping` `altium_detect` `altium_script_state` |+| Inspect | `altium_addin_ping` `altium_list_docs` `altium_list_symbols` `altium_read_symbol` `altium_read_params` |+| Author | `altium_build_symbol` `altium_build_footprint` `altium_add_body` `altium_create_resistor` `altium_create_and_attach` `altium_install_library` |+| Clone / copy | `altium_clone_symbol` `altium_copy_sch_component` `altium_copy_footprint` `altium_import_part` |+| Edit | `altium_add_component` `altium_set_visibility` `altium_rename_component` `altium_rename_footprint` `altium_remove_component` |+| Package | `altium_compile_intlib` `altium_extract_intlib` `altium_cleanup_libs` `altium_close_doc` |+| Batch output | `altium_export_step` `altium_run_outjob` | -## Develop+## What it can't++The library verbs act on `.SchLib` / `.PcbLib` / `.IntLib` only — **no design documents**. There is+no placement, routing, netlist editing, DRC or ERC. To read a `.PcbDoc`, `.PcbLib` or `.SchLib`+without Altium at all, use the native Rust crates (`altium-pcbdoc`, `altium-pcblib`,+`altium-schlib`) — no Windows, no GUI, far faster.++Calls are serialized; overlapping ones make Altium raise a modal that blocks everything until+dismissed.++## Architecture  ```-cd adom-desktop-altium-bridge-adom-wiki pkg pack       # build a tarball locally to inspect-adom-wiki pkg publish    # publish to the registry (runs lint first)+container:  adom-desktop altium_<verb>+    ↓ Adom Desktop relay+Windows:    server.py on :8801              host-side verbs answer here+    ↓ C:\Users\Public\AdomAltium\{request.txt,response.json}+Altium:     X2.EXE -RScriptingSystem:RunScript(AdomAltium>Run) ``` -Edit-in-place once published:+One `X2.EXE` forward per command. DelphiScript cannot host a resident callback server, so this is+the ceiling — the way to go fast is fewer, fatter commands (`altium_import_part` imports a whole+part in a single forward). -```-adom-wiki pkg link adom-desktop-altium-bridge     # point the installed slug at this checkout-# edit files; changes propagate immediately via the symlink convention-adom-wiki pkg unlink adom-desktop-altium-bridge   # restore the published install+## Develop++Source is in `bridge-src/`. Three files each declare what exists — `bridge.json` (`verbs[]`),+`server.py` (`COMMAND_HANDLERS`) and `AdomAltium.pas` (`Dispatch`) — and they have drifted apart+before, shipping verbs the script could not run. `check_wiring.py` proves they agree and runs as a+build gate.++```bash+./bridge-src/deploy.sh 0.0.38                      # wiring check + tests + build+./bridge-src/deploy.sh 0.0.38 --publish            # + push the page repo+./bridge-src/deploy.sh 0.0.38 --publish --install  # + reinstall onto the desktop ``` -## Optional package.json fields+Publishing goes through `adom-wiki repo push` (the git layer), never `pkg publish`, which would+overwrite page files from the tarball. -Add these to `package.json` when relevant:+To iterate on the DelphiScript, drop an edited `AdomAltium.pas` into `C:\Users\Public\AdomAltium\`+and call verbs normally — a newer script there wins and is left alone. `altium_script_state` shows+which copy is live. Fold it back into `bridge-src/addin/AdomAltium/` before publishing. -| Field | Purpose |-|---|---|-| `devDependencies`    | Tools needed for *editing* this package, not running it (e.g. style guides). Installed only with `adom-wiki pkg install --dev`. |-| `peerDependencies`   | Packages this one augments. Auto-installed if missing; shared with the rest of the tree. |-| `optionalDependencies` | Nice-to-have integrations; install failures are non-fatal. |-| `engines.adom-wiki`  | Minimum CLI version, e.g. `">=2.8.0"`. |-| `scope`              | `"runtime"` / `"dev"` / `"either"`: hint to consumers about how this package is meant to be used. |-| `visibility`         | `"public"` (default) or `"private"`. Set on first publish only. |-| `scripts.prepublish` | Runs in the project dir before tarball build. Use for code-gen / asset builds. |-| `scripts.postinstall`| Runs in the module dir after install.sh succeeds. Use for setup. |+See [SKILL.md](SKILL.md) for the full verb reference and the OutJob caveats.
SKILL.md+115−8
@@ -1,25 +1,132 @@ --- name: adom-desktop-altium-bridge-description: Adom Desktop bridge for Altium Designer. Adds LCSC/JLCPCB-tagged components to file-based Altium libraries (symbols, footprints with 3D, value-only display) by driving Altium via DelphiScript. Install when the user wants Claude to add parts to their Altium library. Pairs with the adom-altium-library-importer skill.+description: Adom Desktop bridge for Altium Designer. Drives Altium on the user's Windows machine from a container — build, clone and edit file-based symbol/footprint/Integrated libraries with LCSC/JLCPCB part numbers attached, and generate batch outputs (STEP, PDF, Gerber, BOM, CPL) from an OutJob. Install when the user wants Claude to add parts to their Altium library or export from an Altium board. Pairs with the adom-library-importer skill. ---  # adom-desktop-altium-bridge -A dynamic [Adom Desktop](https://wiki.adom.inc/adom-desktop) bridge that lets cloud Claude drive **Altium Designer** on the user's Windows machine (Altium has no headless/CLI mode — the bridge forwards DelphiScript via `X2.EXE`).+A dynamic [Adom Desktop](https://wiki.adom.inc/adom-desktop) bridge that lets a container drive+**Altium Designer** on the user's Windows machine. Altium has no headless or CLI mode, so the bridge+forwards work into the running GUI as DelphiScript via `X2.EXE`. -## Install (on the user's Windows desktop)+## Install  ```bash-adom-desktop bridge_install '{"manifestUrl":"https://wiki.adom.inc/static/apps/adom-desktop-altium-bridge/altium-bridge-manifest.json"}'-adom-desktop altium_ping '{}'      # -> {"altiumInstalled": true, "version": "..."}+adom-desktop bridge_install '{"manifestUrl":"https://wiki.adom.inc/download/barrett-land/adom-desktop-altium-bridge/latest/altium-bridge-manifest.json"}'+adom-desktop --ai-thread "<your thread>" altium_ping '{}'   # -> {"altiumInstalled": true, "version": "..."} ``` +Every verb needs `--ai-thread`; without it Adom Desktop refuses the call with+`caller_identity_required`.++## How it works++```+container:  adom-desktop altium_<verb>+    ↓ Adom Desktop relay+Windows:    server.py on :8801              host-side verbs answer here+    ↓ C:\Users\Public\AdomAltium\{request.txt,response.json}+Altium:     X2.EXE -RScriptingSystem:RunScript(AdomAltium>Run)+```++One `X2.EXE` forward per command, serialized by a module lock. **The way to go fast is fewer, fatter+commands** — see `altium_import_part`, which does a whole part import in a single forward.+ ## Verbs -`altium_ping` / `altium_detect`; `altium_extract_intlib`; `altium_import_part` (batched one-round-trip import); `altium_build_footprint` (+STEP 3D); `altium_add_body` (extruded 3D); `altium_clone_symbol`; `altium_add_component` (LCSC + params); `altium_set_visibility`; `altium_rename_component` / `altium_rename_footprint`; `altium_install_library` / `altium_cleanup_libs`; `altium_close_doc` / `altium_read_params` / `altium_list_symbols` / `altium_read_symbol`.+**Host-side** (no Altium round-trip, fast): `altium_ping` · `altium_detect` · `altium_script_state`++**Inspect:** `altium_addin_ping` · `altium_list_docs` · `altium_list_symbols` · `altium_read_symbol`+· `altium_read_params`++**Author:** `altium_build_symbol` · `altium_build_footprint` (pads + optional STEP body) ·+`altium_add_body` (extruded 3D) · `altium_create_resistor` · `altium_create_and_attach` ·+`altium_install_library`++**Clone / copy:** `altium_clone_symbol` · `altium_copy_sch_component` · `altium_copy_footprint` ·+`altium_import_part` (batched, one round-trip)++**Edit:** `altium_add_component` (LCSC + identity params) · `altium_set_visibility` ·+`altium_rename_component` · `altium_rename_footprint` · `altium_remove_component`++**Package:** `altium_compile_intlib` · `altium_extract_intlib` · `altium_cleanup_libs` ·+`altium_close_doc`++**Batch output:** `altium_export_step` · `altium_run_outjob`++## What this bridge does NOT do++Be straight with the user about the boundary rather than attempting a workaround:++- **It does not touch designs.** Every library verb operates on `.SchLib` / `.PcbLib` / `.IntLib`.+  There is no placement, routing, netlist, or schematic-sheet editing. To *read* a `.PcbDoc` or+  `.SchLib` without Altium at all, use the native Rust crates (`altium-pcbdoc`, `altium-pcblib`,+  `altium-schlib`) instead — no bridge, no Windows, much faster.+- **No DRC or ERC.** Altium's rule engine is not scriptable through this path.+- **Not concurrent.** One command at a time. Overlapping calls make Altium raise a modal+  "another instance is busy" dialog that then blocks *every* subsequent command until dismissed.+- **Windows only**, and only while Altium is actually running.++## Batch output (STEP / PDF / Gerber / BOM / CPL)++`altium_run_outjob` is the real export path — Altium exposes no direct board-to-STEP process, so+outputs go through an OutJob run by `WorkspaceManager:GenerateReport`.++```bash+adom-desktop --ai-thread "<thread>" altium_run_outjob '{+  "outjob":  "C:\\path\\to\\Outputs.OutJob",+  "project": "C:\\path\\to\\Board.PrjPcb",+  "expect":  "C:\\path\\to\\Project Outputs for Board\\PCB Print\\Composite Drawing.PDF"+}'+```++- Always pass `project`. The script opens **and compiles** it first, which is what populates an+  OutJob's `[Project Physical Documents]` target. Without it the OutJob has nothing to act on.+- Pass `expect` to get an authoritative `out_exists` in the reply instead of inferring success.+- An OutJob targeting `[Project Physical Documents]` is **generic and reusable** across boards —+  add it to the project, compile, run.+- ⚠️ **Known limitation:** `GenerateReport` is observed to no-op in configurations where the GUI's+  *Generate content* button on the same OutJob works. `ran: true` with `out_exists: false` means+  the trigger did not fire — it does **not** mean the OutJob is misconfigured. Ask the user to click+  *Generate content* once rather than rebuilding a working OutJob.++`altium_export_step` opens a `.PcbDoc` and reports that the board resolved. With `run: true` it also+fires the process named by `proc` (default `Pcb:ExportSTEP`, overridable so candidates can be probed+without redeploying the script). If that process opens a modal, the call times out with the export+possibly still pending — **check the output path on disk before concluding it failed.**++## Developing the bridge++Source lives in the page repo under `bridge-src/`. Three files each carry their own list of what+exists, and they have drifted before:++| File | Carries |+|---|---|+| `bridge.json` | `verbs[]` — what Adom Desktop advertises |+| `server.py` | `COMMAND_HANDLERS` — what the HTTP server routes |+| `addin/AdomAltium/AdomAltium.pas` | `Dispatch` — what Altium implements |++`check_wiring.py` proves all three agree and `deploy.sh` runs it as a build gate, so a verb that is+routed but not implemented fails the build instead of shipping.++```bash+./bridge-src/deploy.sh 0.0.38                      # verify + test + build the zip+./bridge-src/deploy.sh 0.0.38 --publish            # + push the page repo+./bridge-src/deploy.sh 0.0.38 --publish --install  # + reinstall onto the desktop+```++**Iterating on the DelphiScript:** drop an edited `AdomAltium.pas` straight into+`C:\Users\Public\AdomAltium\` and call verbs normally — a script there that is newer than the+packaged one wins and is left alone. `altium_script_state` tells you which copy is live and flags+the override; set `ADOM_ALTIUM_NO_SCRIPT_SYNC=1` to stop syncing entirely. Fold the result back into+`bridge-src/addin/AdomAltium/` before publishing, or the next install silently reverts it.  ## Use it -Pair with the **[adom-altium-library-importer](https://wiki.adom.inc/adom-altium-library-importer)** skill, which orchestrates these verbs to add catalog parts (with the JLC part number) to the user's Altium libraries.+Pair with **[adom-library-importer](https://wiki.adom.inc/adom/adom-library-importer)**, which+orchestrates these verbs to add catalog parts (with the JLC part number) to the user's libraries.+It supersedes the deprecated `adom-altium-library-importer`. -> The bridge process is single-threaded: a slow DelphiScript save can report "Request timed out" while the work persists — verify with `altium_read_params` rather than trusting the timeout.+> The bridge is single-threaded: a slow DelphiScript save can report "Request timed out" while the+> work persists. Verify with `altium_read_params` or `altium_list_symbols` rather than trusting the+> timeout — and never fire a retry blind, since that is what raises the blocking modal.
altium-bridge-manifest.json+3−3
@@ -1,3 +1,3 @@-{ "manifest_version": 1, "name": "altium", "version": "0.0.35",-  "url": "https://wiki.adom.inc/download/barrett-land/adom-desktop-altium-bridge/0.0.35/altium-bridge-v0.0.35.zip",-  "sha256": "2caa3ebbf4dc780b8464d3a964556f3ac5bf452065b3a36e92147f1dfed0eddf", "size": 22919, "released_at": "2026-06-22T00:00:00Z" }+{ "manifest_version": 1, "name": "altium", "version": "0.0.37",+  "url": "https://wiki.adom.inc/download/barrett-land/adom-desktop-altium-bridge/latest/altium-bridge-v0.0.37.zip",+  "sha256": "1e9099ece05ec7dcba5dc003f69181825c3f49d35844e65dd2fa211b7db54464", "size": 26085, "released_at": "2026-08-05T20:43:10Z" }
altium-bridge-v0.0.37.zipadded
⋯ 1 unchanged line ⋯
bridge-src/BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.0.33+0.0.37
bridge-src/__pycache__/addin_client.cpython-312.pycadded
⋯ 1 unchanged line ⋯
bridge-src/__pycache__/altium_detect.cpython-312.pycadded
⋯ 1 unchanged line ⋯
bridge-src/__pycache__/check_wiring.cpython-312.pycadded
⋯ 1 unchanged line ⋯
bridge-src/__pycache__/server.cpython-312.pycadded
⋯ 1 unchanged line ⋯
bridge-src/__pycache__/test_addin_client.cpython-312.pycadded
⋯ 1 unchanged line ⋯
bridge-src/addin/AdomAltium/AdomAltium.pas+305
@@ -1399,6 +1399,307 @@ begin            ', "saved": ' + SavedStr); end; +{ ---- command: copy a component from one SchLib (src) into another (dst), appending.+       Faithful copy (pins + graphics + params); designator + footprint link set fresh.+       Enables adding a new part TYPE into an EXISTING library. ---- }+procedure CmdCopySchComponent;+var+    SrcPath, DstPath, CompRef, NewName, FpName, Desig, SavedStr : String;+    SrcDoc, DstDoc : IServerDocument;+    SrcLib, DstLib : ISch_Lib;+    Src, NewCmp    : ISch_Component;+    It   : ISch_Iterator;+    Prim, NewPrim : ISch_GraphicalObject;+    Impl : ISch_Implementation;+    o    : Integer;+    SavedOK : Boolean;+begin+    SrcPath := Req.Values['src'];+    DstPath := Req.Values['dst'];+    CompRef := Req.Values['component'];+    NewName := Req.Values['newname']; if NewName = '' then NewName := CompRef;+    FpName  := Req.Values['footprint'];+    Desig   := Req.Values['designator']; if Desig = '' then Desig := 'U?';+    if (SrcPath = '') or (DstPath = '') then begin RespErr('need src + dst'); Exit; end;+    if SchServer = nil then begin RespErr('SchServer nil'); Exit; end;++    GStage := 'open src schlib';+    SrcDoc := Client.OpenDocument('SCHLIB', SrcPath);+    if SrcDoc <> nil then Client.ShowDocument(SrcDoc);+    SrcLib := SchServer.GetSchDocumentByPath(SrcPath);+    if SrcLib = nil then begin RespErr('open src failed: ' + SrcPath); Exit; end;++    GStage := 'find src component';+    Src := FindComponent(SrcLib, CompRef);+    if Src = nil then begin RespErr('component not found in src: ' + CompRef); Exit; end;++    GStage := 'open dst schlib';+    DstDoc := Client.OpenDocument('SCHLIB', DstPath);+    if DstDoc <> nil then Client.ShowDocument(DstDoc);+    DstLib := SchServer.GetSchDocumentByPath(DstPath);+    if DstLib = nil then begin RespErr('open dst failed: ' + DstPath); Exit; end;+    LastServerDoc := DstDoc;++    GStage := 'create new component';+    NewCmp := SchServer.SchObjectFactory(eSchComponent, eCreate_Default);+    NewCmp.CurrentPartID := 1;+    NewCmp.DisplayMode   := 0;+    NewCmp.LibReference  := NewName;+    NewCmp.DesignItemID  := NewName;++    GStage := 'copy primitives';+    It := Src.SchIterator_Create;        { no filter — copy pins + graphics + params }+    try+        Prim := It.FirstSchObject;+        while Prim <> nil do+        begin+            o := Prim.ObjectId;+            { skip designator/comment/implementation (25/45/46); they are set fresh below }+            if (o <> 25) and (o <> 45) and (o <> 46) then+            begin+                NewPrim := Prim.Replicate;+                NewPrim.OwnerPartId          := NewCmp.CurrentPartID;+                NewPrim.OwnerPartDisplayMode := NewCmp.DisplayMode;+                NewCmp.AddSchObject(NewPrim);+            end;+            Prim := It.NextSchObject;+        end;+    finally+        Src.SchIterator_Destroy(It);+    end;++    GStage := 'designator + footprint link';+    NewCmp.Designator.Text := Desig;+    if FpName <> '' then+    begin+        Impl := NewCmp.AddSchImplementation;+        Impl.ClearAllDatafileLinks;+        Impl.ModelName := FpName;+        Impl.ModelType := 'PCBLIB';+        Impl.AddDataFileLink(FpName, '', 'PCBLIB');+        Impl.IsCurrent := True;+    end;++    GStage := 'add to dst + save';+    DstLib.AddSchComponent(NewCmp);+    SchServer.RobotManager.SendMessage(DstLib.I_ObjectAddress, c_BroadCast,+                                       SCHM_PrimitiveRegistration, NewCmp.I_ObjectAddress);+    DstLib.GraphicallyInvalidate;+    SavedOK := False;+    if DstDoc <> nil then SavedOK := DstDoc.DoFileSave('');+    if SavedOK then SavedStr := 'true' else SavedStr := 'false';+    RespOk(', "copied": ' + JStr(CompRef) + ' -> ' + JStr(NewName) ++           ', "into": ' + JStr(DstPath) + ', "saved": ' + SavedStr);+end;++{ ---- command: copy a footprint from one PcbLib (src) into another (dst), appending. ---- }+procedure CmdCopyFootprint;+var+    SrcPath, DstPath, FpName, NewName, SavedStr : String;+    SrcDoc, DstDoc : IServerDocument;+    SrcLib, DstLib : IPCB_Library;+    SrcFp, NewFp   : IPCB_LibComponent;+    GIter : IPCB_GroupIterator;+    Prim, NewPrim : IPCB_Primitive;+    i : Integer;+    Found, SavedOK : Boolean;+begin+    SrcPath := Req.Values['src'];+    DstPath := Req.Values['dst'];+    FpName  := Req.Values['footprint'];+    NewName := Req.Values['newname'];+    if (SrcPath = '') or (DstPath = '') then+    begin RespErr('need src + dst'); Exit; end;+    if PCBServer = nil then begin RespErr('PCBServer nil'); Exit; end;++    GStage := 'open src pcblib';+    SrcDoc := Client.OpenDocument('PCBLIB', SrcPath);+    if SrcDoc <> nil then Client.ShowDocument(SrcDoc);+    SrcLib := PCBServer.GetCurrentPCBLibrary;+    if SrcLib = nil then begin RespErr('no src pcblib'); Exit; end;++    GStage := 'find src footprint';+    Found := False;+    for i := 0 to SrcLib.ComponentCount - 1 do+    begin+        SrcFp := SrcLib.GetComponent(i);+        if (SrcFp <> nil) and ((FpName = '') or (SrcFp.Name = FpName)) then begin Found := True; Break; end;+    end;+    if not Found then begin RespErr('footprint not found in src: ' + FpName); Exit; end;+    if NewName = '' then NewName := SrcFp.Name;   { keep source name if no rename given }++    GStage := 'open dst pcblib';+    DstDoc := Client.OpenDocument('PCBLIB', DstPath);+    if DstDoc <> nil then Client.ShowDocument(DstDoc);+    DstLib := PCBServer.GetCurrentPCBLibrary;+    if DstLib = nil then begin RespErr('no dst pcblib'); Exit; end;+    LastServerDoc := DstDoc;++    GStage := 'create new footprint';+    PCBServer.PreProcess;+    NewFp := PCBServer.CreatePCBLibComp;+    NewFp.Name := NewName;+    DstLib.RegisterComponent(NewFp);++    GStage := 'copy primitives';+    GIter := SrcFp.GroupIterator_Create;+    try+        Prim := GIter.FirstPCBObject;+        while Prim <> nil do+        begin+            NewPrim := Prim.Replicate;+            NewFp.AddPCBObject(NewPrim);+            PCBServer.SendMessageToRobots(NewFp.I_ObjectAddress, c_Broadcast,+                                          PCBM_BoardRegisteration, NewPrim.I_ObjectAddress);+            Prim := GIter.NextPCBObject;+        end;+    finally+        SrcFp.GroupIterator_Destroy(GIter);+    end;+    PCBServer.PostProcess;++    GStage := 'save dst';+    DstLib.Board.ViewManager_FullUpdate;+    DstDoc.SetFileName(DstPath);+    SavedOK := DstDoc.DoFileSave('');+    if SavedOK then SavedStr := 'true' else SavedStr := 'false';+    RespOk(', "copied_footprint": ' + JStr(FpName) + ' -> ' + JStr(NewName) ++           ', "into": ' + JStr(DstPath) + ', "saved": ' + SavedStr);+end;++{ ---- command: probe/open a BOARD (.PcbDoc) via the PCB API. This is the first+       half of the molecule STEP-export path (altium-export-molecule Stage 1):+       prove we can open a board document and obtain its IPCB_Board on this+       Altium build, WITHOUT firing any export process yet (a modal export+       dialog would hang the -Wait bridge call). The STEP export itself is+       wired in a follow-up once this board-open half is confirmed.+       req: pcbdoc=<path to .PcbDoc>  ---- }+procedure CmdExportStep;+var+    InPath, OutPath, ProcId : String;+    SD     : IServerDocument;+    Board  : IPCB_Board;+begin+    InPath := Req.Values['pcbdoc'];+    if InPath = '' then begin RespErr('export_step: missing pcbdoc'); Exit; end;+    if not FileExists(InPath) then begin RespErr('export_step: pcbdoc not found: ' + InPath); Exit; end;++    GStage := 'export_step: open board';+    SD := Client.OpenDocument('PCB', InPath);+    if SD = nil then begin RespErr('export_step: OpenDocument(PCB) returned nil'); Exit; end;+    Client.ShowDocument(SD);++    GStage := 'export_step: GetCurrentPCBBoard';+    if PCBServer = nil then begin RespErr('export_step: PCBServer nil'); Exit; end;+    Board := PCBServer.GetCurrentPCBBoard;+    if Board = nil then begin RespErr('export_step: GetCurrentPCBBoard nil'); Exit; end;++    { run=true: attempt the actual STEP export via a server process. The process+      id is overridable via req 'proc' so we can probe candidates without+      re-deploying. If the process opens a modal, RunProcess blocks here and no+      response is written — the caller detects that via poll-timeout and recovers+      (screenshot + dismiss). Sandbox only until a non-modal path is confirmed. }+    if Req.Values['run'] = 'true' then+    begin+        OutPath := Req.Values['out'];+        if OutPath = '' then begin RespErr('export_step: run=true needs out'); Exit; end;+        ProcId := Req.Values['proc'];+        if ProcId = '' then ProcId := 'Pcb:ExportSTEP';+        if FileExists(OutPath) then DeleteFile(OutPath);+        GStage := 'export_step: RunProcess ' + ProcId;+        ResetParameters;+        AddStringParameter('FileName', OutPath);+        RunProcess(ProcId);+        RespOk(', "pcbdoc": ' + JStr(InPath) ++               ', "ran": true, "proc": ' + JStr(ProcId) ++               ', "out": ' + JStr(OutPath) ++               ', "out_exists": ' + BoolStr(FileExists(OutPath)));+        Exit;+    end;++    RespOk(', "pcbdoc": ' + JStr(InPath) ++           ', "opened": true' ++           ', "export_wired": false' ++           ', "board_name": ' + JStr(Board.FileName));+end;++{ ---- command: run an OutJob output (e.g. an ExportSTEP output) headlessly via+       WorkspaceManager:GenerateReport. This is the sanctioned headless STEP-export+       path (Altium exposes no direct board->STEP process). Action/ObjectKind/Medium+       are overridable from the request so the exact GenerateReport params can be+       tuned WITHOUT redeploying the script.+       req: outjob=<path.OutJob>, action(=Run), objectkind(=OutputBatch),+            medium(optional OutputMedium name) ---- }+procedure CmdRunOutJob;+var+    OjPath, Action, ObjKind, Medium, PrjPath, Expect : String;+    SD : IServerDocument;+    WS : IWorkspace;+    Proj : IProject;+    ProjStr : String;+begin+    OjPath := Req.Values['outjob'];+    if OjPath = '' then begin RespErr('run_outjob: missing outjob'); Exit; end;+    if not FileExists(OjPath) then begin RespErr('run_outjob: outjob not found: ' + OjPath); Exit; end;+    Action  := Req.Values['action'];     if Action = '' then Action := 'Run';+    ObjKind := Req.Values['objectkind']; if ObjKind = '' then ObjKind := 'OutputBatch';+    Medium  := Req.Values['medium'];+    PrjPath := Req.Values['project'];+    Expect  := Req.Values['expect'];+    ProjStr := 'false';++    { Open + focus the owning project FIRST so an OutJob's [Project Physical+      Documents] target resolves. An OutJob opened standalone binds to the+      focused project as its container. }+    if PrjPath <> '' then+    begin+        if not FileExists(PrjPath) then begin RespErr('run_outjob: project not found: ' + PrjPath); Exit; end;+        GStage := 'run_outjob: open project';+        WS := GetWorkspace;+        if WS = nil then begin RespErr('run_outjob: GetWorkspace nil'); Exit; end;+        Proj := WS.DM_OpenProject(PrjPath, True);+        if Proj = nil then begin RespErr('run_outjob: DM_OpenProject nil'); Exit; end;+        Proj.DM_Compile;+        ProjStr := 'true';+    end;++    { Altium may already hold this OutJob OPEN in the editor with a STALE in-memory+      copy; OpenDocument would return that cached version and ignore disk edits.+      Close it first (unmodified from Altium's view -> no save prompt) so the reopen+      reads the freshly-deployed file from disk. Skip with reload=false to use the+      already-loaded (e.g. GUI-configured) in-memory copy verbatim. }+    if Req.Values['reload'] <> 'false' then+    begin+        GStage := 'run_outjob: close stale outjob';+        SD := Client.OpenDocument('OUTPUTJOB', OjPath);+        if SD <> nil then Client.CloseDocument(SD);+    end;++    GStage := 'run_outjob: open outjob';+    SD := Client.OpenDocument('OUTPUTJOB', OjPath);+    if SD = nil then begin RespErr('run_outjob: OpenDocument(OUTPUTJOB) nil'); Exit; end;+    Client.ShowDocument(SD);++    if (Expect <> '') and FileExists(Expect) then DeleteFile(Expect);++    GStage := 'run_outjob: GenerateReport';+    ResetParameters;+    AddStringParameter('Action', Action);+    AddStringParameter('ObjectKind', ObjKind);+    if Medium <> '' then AddStringParameter('OutputMedium', Medium);+    if Req.Values['disable_dialog'] <> 'false' then AddStringParameter('DisableDialog', 'True');+    RunProcess('WorkspaceManager:GenerateReport');++    RespOk(', "outjob": ' + JStr(OjPath) ++           ', "action": ' + JStr(Action) ++           ', "objectkind": ' + JStr(ObjKind) ++           ', "medium": ' + JStr(Medium) ++           ', "project_opened": ' + ProjStr ++           ', "expect": ' + JStr(Expect) ++           ', "out_exists": ' + BoolStr((Expect <> '') and FileExists(Expect)) ++           ', "ran": true');+end;+ { ---- shared command dispatch. Assumes the global Req is loaded and GReqId set. ---- } procedure Dispatch; var Cmd : String;@@ -1426,8 +1727,12 @@ begin         else if Cmd = 'rename_component' then CmdRenameComponent         else if Cmd = 'rename_footprint' then CmdRenameFootprint         else if Cmd = 'import_part'      then CmdImportPart+        else if Cmd = 'copy_sch_component' then CmdCopySchComponent+        else if Cmd = 'copy_footprint'   then CmdCopyFootprint         else if Cmd = 'add_body'         then CmdAddBody         else if Cmd = 'set_visibility'   then CmdSetVisibility+        else if Cmd = 'export_step'      then CmdExportStep+        else if Cmd = 'run_outjob'       then CmdRunOutJob         else RespErr('unknown command: ' + Cmd);     except         RespErr('exception at stage: ' + GStage);
bridge-src/addin_client.py+74−1
@@ -20,6 +20,7 @@ A module lock serializes the file exchange because the bridge HTTP server is threaded (request.txt / response.json are shared, one command at a time). """ +import hashlib import json import os import shutil@@ -34,15 +35,87 @@ EXCHANGE_DIR = r"C:\Users\Public\AdomAltium" ADDIN_SRC = Path(__file__).parent / "addin" / "AdomAltium" SCRIPT_FILES = ("AdomAltium.pas", "AdomAltium.PrjScr") +# Set to 1 to stop syncing the packaged scripts into the exchange dir entirely.+NO_SYNC_ENV = "ADOM_ALTIUM_NO_SCRIPT_SYNC"+ _LOCK = threading.Lock() +# Per-file note on what _ensure_scripts last did: "synced" | "same" | "kept-newer"+# | "skipped-env". Surfaced by script_state() so a caller can tell WHICH script is+# actually live without shelling onto the desktop.+_SYNC_NOTES = {}+++def _sha256(path):+    h = hashlib.sha256()+    with open(path, "rb") as fh:+        for chunk in iter(lambda: fh.read(65536), b""):+            h.update(chunk)+    return h.hexdigest()+  def _ensure_scripts():+    """Sync the packaged AdomAltium scripts into the exchange dir, NON-destructively.++    The exchange dir is also where a script is hand-deployed during development, so+    an unconditional copy silently destroyed in-progress work: every verb call+    reverted C:\\Users\\Public\\AdomAltium\\AdomAltium.pas to the packaged copy, and+    the only way to test an edited script was to bypass this client and drive+    X2.EXE directly. A newer file in the exchange dir now WINS and is left alone.+    """     os.makedirs(EXCHANGE_DIR, exist_ok=True)+    if os.environ.get(NO_SYNC_ENV) == "1":+        for fname in SCRIPT_FILES:+            _SYNC_NOTES[fname] = "skipped-env"+        return++    for fname in SCRIPT_FILES:+        src = ADDIN_SRC / fname+        if not src.exists():+            continue+        dst = os.path.join(EXCHANGE_DIR, fname)++        if not os.path.exists(dst):+            shutil.copyfile(str(src), dst)+            _SYNC_NOTES[fname] = "synced"+            continue++        if _sha256(str(src)) == _sha256(dst):+            _SYNC_NOTES[fname] = "same"+            continue++        # Different content: the newer file wins. mtime is meaningful here because+        # copyfile() does NOT preserve it — a synced file always carries its sync+        # time, so a later mtime means someone deployed by hand after that sync.+        if os.path.getmtime(dst) > src.stat().st_mtime:+            _SYNC_NOTES[fname] = "kept-newer"+            continue++        shutil.copyfile(str(src), dst)+        _SYNC_NOTES[fname] = "synced"+++def script_state():+    """Which in-Altium script is live, and is it a dev override of the packaged one?++    Cheap (no Altium round-trip) and the fastest way to diagnose "the verb exists in+    server.py but the script says unknown command" — the two halves drifting apart.+    """+    out = {"exchangeDir": EXCHANGE_DIR, "scripts": {}}     for fname in SCRIPT_FILES:         src = ADDIN_SRC / fname+        dst = os.path.join(EXCHANGE_DIR, fname)+        entry = {"note": _SYNC_NOTES.get(fname, "not-yet-synced")}         if src.exists():-            shutil.copyfile(str(src), os.path.join(EXCHANGE_DIR, fname))+            entry["packagedSha256"] = _sha256(str(src))[:16]+            entry["packagedBytes"] = src.stat().st_size+        if os.path.exists(dst):+            entry["liveSha256"] = _sha256(dst)[:16]+            entry["liveBytes"] = os.path.getsize(dst)+        entry["devOverride"] = entry.get("note") == "kept-newer"+        out["scripts"][fname] = entry+    out["devOverride"] = any(s.get("devOverride") for s in out["scripts"].values())+    return out   def _write_request(command, params):
bridge-src/bridge.json+9−4
@@ -2,8 +2,8 @@   "manifest_version": 1,   "name": "altium",   "displayName": "Altium Bridge",-  "version": "0.0.33",-  "description": "Adds components to an Altium Designer library (Integrated, Database, or Altium 365) with the LCSC/JLCPCB part number attached as a parameter, and exports JLC-ready BOM + pick-and-place (CPL). Pairs with the container-side altium-jlc orchestrator (adom-jlcpcb + chip-fetcher).",+  "version": "0.0.37",+  "description": "Drives Altium Designer from a container: build, clone and edit file-based symbol/footprint/Integrated libraries with LCSC/JLCPCB part numbers attached, and generate batch outputs (STEP, PDF, Gerber, BOM, CPL) from an OutJob. Pairs with the container-side altium-jlc orchestrator (adom-jlcpcb + chip-fetcher).",   "homepage": "https://wiki.adom.inc/adom/adom-desktop-altium-bridge",   "author": "Adom Inc.",   "license": "MIT",@@ -42,7 +42,12 @@     "altium_rename_footprint",     "altium_import_part",     "altium_add_body",-    "altium_set_visibility"+    "altium_set_visibility",+    "altium_copy_sch_component",+    "altium_copy_footprint",+    "altium_script_state",+    "altium_export_step",+    "altium_run_outjob"   ],   "dependencies": {     "python": ">=3.8"@@ -70,4 +75,4 @@       "reason": "Altium Designer is Windows-only; no host app to drive on Linux."     }   }-}\ No newline at end of file+}
bridge-src/check_wiring.pyadded+101
@@ -0,0 +1,101 @@+#!/usr/bin/env python3+"""check_wiring — prove the three halves of this bridge agree, before publishing.++The bridge is three files that each carry their own list of what exists:++    bridge.json   verbs[]            what Adom Desktop advertises+    server.py     COMMAND_HANDLERS   what the HTTP server routes+    AdomAltium.pas  Dispatch         what Altium actually implements++Nothing enforced that they matched, and they drifted: the shipped package routed+verbs its own script could not run, and the repo's readable source was two verbs+behind the zip that was actually installed. Run this in deploy.sh so a mismatch+fails the build instead of shipping.++Pure static parsing (regex over the sources) so it runs in the Linux container,+where the Windows-only bridge modules cannot be imported.++Usage:  python3 check_wiring.py [bridge-src-dir]   -> exit 0 clean, 1 on mismatch+"""++import json+import re+import sys+from pathlib import Path++# Verbs the server answers itself, with no in-Altium counterpart to match against.+HOST_ONLY = {"altium_ping", "altium_detect", "altium_script_state"}+++def routed_verbs(server_py: Path) -> dict:+    """{verb: pas_command_or_None} from COMMAND_HANDLERS plus each handler's body."""+    text = server_py.read_text(encoding="utf-8")++    table = re.search(r"COMMAND_HANDLERS\s*=\s*\{(.*?)\n\}", text, re.S)+    if not table:+        sys.exit("check_wiring: COMMAND_HANDLERS table not found in server.py")+    pairs = re.findall(r'"(altium_[a-z_]+)"\s*:\s*(\w+)', table.group(1))++    # Map handler function -> the addin_call command it issues (if any).+    bodies = re.split(r"\ndef ", text)+    handler_cmd = {}+    for body in bodies:+        name = re.match(r"(\w+)\s*\(", body)+        if not name:+            continue+        call = re.search(r'addin_call\(\s*"([a-z_]+)"', body)+        handler_cmd[name.group(1)] = call.group(1) if call else None++    return {verb: handler_cmd.get(fn) for verb, fn in pairs}+++def pas_commands(pas: Path) -> set:+    """Command strings the DelphiScript Dispatch procedure compares against."""+    text = pas.read_text(encoding="utf-8", errors="replace")+    block = re.search(r"procedure Dispatch;(.*?)\nend;", text, re.S)+    if not block:+        sys.exit("check_wiring: Dispatch procedure not found in AdomAltium.pas")+    return set(re.findall(r"Cmd\s*=\s*'([a-z_]+)'", block.group(1)))+++def main() -> int:+    root = Path(sys.argv[1] if len(sys.argv) > 1 else Path(__file__).parent)+    routed = routed_verbs(root / "server.py")+    implemented = pas_commands(root / "addin" / "AdomAltium" / "AdomAltium.pas")+    advertised = set(json.loads((root / "bridge.json").read_text())["verbs"])++    problems = []++    # 1. Every routed verb must reach a command the script implements.+    for verb, cmd in sorted(routed.items()):+        if verb in HOST_ONLY:+            continue+        if cmd is None:+            problems.append(f"{verb}: handler issues no addin_call (host-only? add to HOST_ONLY)")+        elif cmd not in implemented:+            problems.append(f"{verb}: routes to '{cmd}', which AdomAltium.pas does not dispatch")++    # 2. bridge.json is what Adom Desktop shows the user; an omission hides a verb.+    for verb in sorted(set(routed) - advertised):+        problems.append(f"{verb}: routed by server.py but missing from bridge.json verbs[]")+    for verb in sorted(advertised - set(routed)):+        problems.append(f"{verb}: advertised in bridge.json but not routed by server.py")++    # 3. Script commands with no verb are dead weight — a warning, not a failure:+    #    'add_lcsc_param' is legitimately reached via the altium_add_component verb.+    unreachable = implemented - {c for c in routed.values() if c}+    if unreachable:+        print(f"note: script commands with no verb: {', '.join(sorted(unreachable))}")++    if problems:+        print(f"\nFAIL — {len(problems)} wiring mismatch(es):")+        for p in problems:+            print(f"  - {p}")+        return 1++    print(f"OK — {len(routed)} verbs routed, {len(implemented)} script commands, bridge.json agrees.")+    return 0+++if __name__ == "__main__":+    sys.exit(main())
bridge-src/deploy.sh+113−30
@@ -1,42 +1,125 @@ #!/usr/bin/env bash-# deploy.sh — build + publish + (re)install the altium bridge in one shot.-# Usage: ./deploy.sh <new-version>   e.g. ./deploy.sh 0.0.30+# deploy.sh — verify, build, publish and (re)install the Altium bridge.+#+#   ./deploy.sh <version> [--publish] [--install]+#   ./deploy.sh 0.0.37                  # verify + build the zip locally, nothing else+#   ./deploy.sh 0.0.37 --publish        # + push the page repo to wiki.adom.inc+#   ./deploy.sh 0.0.37 --publish --install   # + reinstall onto the Windows desktop+#+# Replaces the previous version, which could no longer run: it built from a+# $ROOT/altium-bridge directory that does not exist, and served the zip off a+# hardcoded :8088 proxy on a different container. The source of truth is now this+# repo, and the artifact is served by the wiki itself.+#+# Publishing uses `adom-wiki repo push` (the git layer) and NOT `pkg publish`,+# which overwrites page files such as package.json and README from the tarball. set -euo pipefail -VER="${1:?usage: deploy.sh <version>}"-ROOT="/home/adom/project"-DIR="$ROOT/altium-bridge"-ZIP="$ROOT/altium-bridge-v${VER}.zip"-MANIFEST="$ROOT/altium-bridge-manifest.json"-BASE="https://barrett-land-dashboard-35mvr0hdyp1h.adom.cloud/proxy/8088"-KILL_PS1="C:\\Users\\barre\\Downloads\\altium\\kill_altium_bridge.ps1"--echo "$VER" > "$DIR/BRIDGE_VERSION"-python3 - "$DIR/bridge.json" "$VER" <<'PY'-import json, sys-p, ver = sys.argv[1], sys.argv[2]-d = json.load(open(p)); d["version"] = ver-json.dump(d, open(p, "w"), indent=2)+VER="${1:?usage: deploy.sh <version> [--publish] [--install]}"; shift || true+DO_PUBLISH=0; DO_INSTALL=0+for a in "$@"; do+  case "$a" in+    --publish) DO_PUBLISH=1 ;;+    --install) DO_INSTALL=1 ;;+    *) echo "unknown flag: $a" >&2; exit 2 ;;+  esac+done++SRC="$(cd "$(dirname "$0")" && pwd)"     # bridge-src/+REPO="$(dirname "$SRC")"                 # page repo root+REF="barrett-land/adom-desktop-altium-bridge"+BASE="https://wiki.adom.inc/download/${REF}/latest"+ZIP_NAME="altium-bridge-v${VER}.zip"+ZIP="$REPO/$ZIP_NAME"+MANIFEST="$REPO/altium-bridge-manifest.json"++# Adom Desktop attributes every command to a thread; without this the call is refused.+AI_THREAD="${ADOM_AI_THREAD:-altium-bridge-deploy}"+ad() { adom-desktop --ai-thread "$AI_THREAD" "$@"; }++# --- 1. verify the three halves agree before anything is built ----------------+echo "==> checking verb wiring"+python3 "$SRC/check_wiring.py" "$SRC"+echo "==> running tests"+( cd "$SRC" && python3 -m unittest discover -p 'test_*.py' -q )+python3 -m py_compile "$SRC"/*.py++# --- 2. stamp the version into every file that carries one -------------------+echo "==> stamping v${VER}"+echo "$VER" > "$SRC/BRIDGE_VERSION"+python3 - "$VER" "$SRC/bridge.json" "$REPO/package.json" <<'PY'+import json, collections, sys+ver, paths = sys.argv[1], sys.argv[2:]+for p in paths:+    d = json.load(open(p), object_pairs_hook=collections.OrderedDict)+    d["version"] = ver+    with open(p, "w") as fh:+        json.dump(d, fh, indent=2); fh.write("\n")+    print(f"    {p} -> {ver}") PY -rm -f "$ZIP"-( cd "$DIR" && zip -rq "$ZIP" . -x '*__pycache__*' -x '*.pyc' )+# --- 3. build the artifact ---------------------------------------------------+echo "==> building $ZIP_NAME"+# Exactly one zip in the repo at a time. Remember which ones we drop so they can be+# removed from the page too — a superseded zip stays served until it is git-rm'd.+OLD_ZIPS=()+for z in "$REPO"/altium-bridge-v*.zip; do+  [ -e "$z" ] || continue+  [ "$(basename "$z")" = "$ZIP_NAME" ] && continue+  OLD_ZIPS+=("$(basename "$z")")+done+rm -f "$REPO"/altium-bridge-v*.zip+( cd "$SRC" && zip -rq "$ZIP" . \+    -x '*__pycache__*' -x '*.pyc' -x 'check_wiring.py' -x 'test_*.py' -x 'deploy.sh' ) SHA=$(sha256sum "$ZIP" | cut -d' ' -f1) SIZE=$(stat -c%s "$ZIP")++# The url MUST use the /latest/ segment: /download/<owner>/<slug>/<version>/<file>+# is not a route the wiki serves and 404s at install time. cat > "$MANIFEST" <<EOF { "manifest_version": 1, "name": "altium", "version": "${VER}",-  "url": "${BASE}/altium-bridge-v${VER}.zip",-  "sha256": "${SHA}", "size": ${SIZE}, "released_at": "2026-06-19T00:00:00Z" }+  "url": "${BASE}/${ZIP_NAME}",+  "sha256": "${SHA}", "size": ${SIZE}, "released_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)" } EOF-echo "built v${VER}  size=${SIZE}  sha=${SHA}"+echo "    ${ZIP_NAME}  size=${SIZE}  sha256=${SHA:0:16}…"++[ "$DO_PUBLISH" = 1 ] || { echo "==> built only (pass --publish to ship)"; exit 0; }++# --- 4. publish the page repo ------------------------------------------------+echo "==> pushing page repo to $REF"+# `repo push` takes an explicit file list, so enumerate everything tracked. Skip the+# .git dir and any stale zip that step 3 already deleted from disk.+mapfile -t FILES < <(cd "$REPO" && find . -type f -not -path './.git/*' -printf '%P\n' | sort)+printf '    %s\n' "${FILES[@]}"+( cd "$REPO" && adom-wiki repo push "$REF" --message "altium bridge v${VER}" --files "${FILES[@]}" )++for old in ${OLD_ZIPS+"${OLD_ZIPS[@]}"}; do+  echo "    removing superseded $old"+  adom-wiki repo rm "$REF" "$old" || true+done++echo "==> verifying the published manifest resolves"+PUB_URL="${BASE}/altium-bridge-manifest.json"+curl -fsS "$PUB_URL" > /tmp/altium-manifest-check.json+curl -fsS -o /dev/null "${BASE}/${ZIP_NAME}"+python3 -c "+import json; d=json.load(open('/tmp/altium-manifest-check.json'))+assert d['version']=='${VER}', f\"served manifest is v{d['version']}, expected ${VER}\"+print('    manifest + zip both resolve at v${VER}')"++[ "$DO_INSTALL" = 1 ] || { echo "==> published (pass --install to update the desktop)"; exit 0; } -# kill ONLY the altium bridge python PIDs (never a broad python kill), then reinstall.-# `|| true` so a "no running process" case doesn't abort the deploy under pipefail.-{ adom-desktop shell_execute "{\"command\":\"powershell -NoProfile -File ${KILL_PS1}\"}" \-  | python3 -c "import sys,json;print('kill:', (json.load(sys.stdin).get('output') or '').strip() or 'no match')"; } || true+# --- 5. reinstall onto the Windows desktop -----------------------------------+echo "==> installing onto the desktop"+ad bridge_install "{\"manifestUrl\":\"${PUB_URL}\"}" \+  | python3 -c "import sys,json;d=json.load(sys.stdin);o=json.loads(d['output']) if isinstance(d.get('output'),str) and d['output'] else d;print('    install ok:',o.get('ok'),o.get('error') or '')" sleep 2-adom-desktop bridge_install "{\"manifestUrl\":\"${BASE}/altium-bridge-manifest.json\"}" \-  | python3 -c "import sys,json;d=json.load(sys.stdin);o=json.loads(d['output']) if isinstance(d.get('output'),str) and d['output'] else d;print('install ok:', o.get('ok'), o.get('error') or '')"-sleep 1-adom-desktop altium_ping '{}' \-  | python3 -c "import sys,json;d=json.load(sys.stdin);o=json.loads(d.get('output','{}')) if d.get('output') else {};print('bridge now v', o.get('version'))"+ad altium_ping '{}' \+  | python3 -c "+import sys, json+d = json.load(sys.stdin)+o = json.loads(d.get('output') or '{}')+live = o.get('version')+print('    bridge now v%s' % live)+sys.exit(0 if live == '${VER}' else 'FAILED: desktop still reports v%s — the old process may still hold the port' % live)"+echo "==> done"
bridge-src/server.py+103−3
@@ -7,8 +7,10 @@ dynamic-bridge contract:     POST /command  body {"command"|"verb": "altium_*", "args": {...}}         -> {"success": true, "output": "<json-string>"}  |  {"success": false, "error", "_hint"} -Phase 0 verbs: altium_ping, altium_detect. Later phases add the IntLib/DbLib/365-component-add verbs and BOM/CPL export (those route to the in-Altium add-in on :8775).+Host-side verbs (no Altium round-trip): altium_ping, altium_detect, altium_script_state.+Everything else forwards into the in-Altium AdomAltium DelphiScript dispatcher via+addin_client — library authoring (symbols, footprints, IntLibs) plus the batch-output+verbs altium_export_step and altium_run_outjob.  Usage:  python server.py [--port 8801] """@@ -22,7 +24,7 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).parent))  from altium_detect import detect_altium-from addin_client import addin_call+from addin_client import NO_SYNC_ENV, addin_call, script_state  DEFAULT_PORT = 8801 @@ -192,6 +194,22 @@ def handle_import_part(args: dict) -> dict:                       timeout=args.get("timeout", 180))  +def handle_copy_sch_component(args: dict) -> dict:+    """Append a component from one SchLib (src) into another (dst) — faithful copy+    (pins+graphics+params), fresh designator + footprint link. Adds a new part TYPE+    into an EXISTING library."""+    keys = ("src", "dst", "component", "newname", "footprint", "designator")+    return addin_call("copy_sch_component", {k: args.get(k) for k in keys},+                      timeout=args.get("timeout", 150))+++def handle_copy_footprint(args: dict) -> dict:+    """Append a footprint from one PcbLib (src) into another (dst)."""+    keys = ("src", "dst", "footprint", "newname")+    return addin_call("copy_footprint", {k: args.get(k) for k in keys},+                      timeout=args.get("timeout", 150))++ def handle_list_symbols(args: dict) -> dict:     return addin_call("list_symbols", {"schlib": args.get("schlib")}, timeout=args.get("timeout", 60)) @@ -239,9 +257,89 @@ def handle_cleanup_libs(args: dict) -> dict:     return addin_call("cleanup_libs", {"match": match}, timeout=args.get("timeout", 60))  +def handle_script_state(args: dict) -> dict:+    """Which in-Altium script is live vs. the one this bridge package ships.++    No Altium round-trip. Use it when a verb routes fine here but the script answers+    'unknown command' — that means the two halves have drifted apart.+    """+    state = script_state()+    state["ok"] = True+    state["bridgeVersion"] = VERSION+    if state.get("devOverride"):+        state["_hint"] = (+            "A hand-deployed script in the exchange dir is newer than the packaged one "+            "and is being used as-is. Fold it back into bridge-src/addin/AdomAltium/ "+            f"and re-publish, or delete it to fall back to the packaged copy. Set "+            f"{NO_SYNC_ENV}=1 to stop syncing altogether."+        )+    return state+++def handle_export_step(args: dict) -> dict:+    """Export a .PcbDoc's 3D board assembly to STEP.++    Two modes. Without run=true it only OPENS the board and reports that it resolved+    (the safe probe). With run=true it also fires the export process named by 'proc'+    (default Pcb:ExportSTEP) writing to 'out'.++    Altium exposes no documented headless board->STEP process, so 'proc' is+    overridable to probe candidates without redeploying the script. If the process+    opens a modal dialog the script blocks and never writes a response — the call+    times out with the export possibly still pending. Check 'out' on disk before+    assuming failure.+    """+    if not args.get("pcbdoc"):+        return {"ok": False, "error": "missing 'pcbdoc' (path to the .PcbDoc on the desktop)"}+    if args.get("run") and not args.get("out"):+        return {"ok": False, "error": "run=true also needs 'out' (destination .step path)"}+    params = {+        "pcbdoc": args.get("pcbdoc"),+        "run": "true" if args.get("run") else None,+        "out": args.get("out"),+        "proc": args.get("proc"),+    }+    return addin_call("export_step", params, timeout=args.get("timeout", 240))+++def handle_run_outjob(args: dict) -> dict:+    """Generate an OutJob's outputs (STEP, PDF, Gerber, BOM, CPL) via+    WorkspaceManager:GenerateReport — the sanctioned batch-output path.++    Pass 'project' so an OutJob targeting [Project Physical Documents] resolves: the+    script opens and COMPILES that project first, which is what populates the target.+    Pass 'expect' (a path the run should produce) to get an authoritative out_exists+    in the reply instead of guessing from the process return.++    KNOWN LIMITATION: GenerateReport is observed to no-op in some configurations+    where the GUI's 'Generate content' button on the same OutJob works. Treat+    out_exists=false with ran=true as "the trigger did not fire", not "the OutJob is+    misconfigured", and verify by hand before rebuilding the OutJob.+    """+    if not args.get("outjob"):+        return {"ok": False, "error": "missing 'outjob' (path to the .OutJob on the desktop)"}+    params = {+        "outjob": args.get("outjob"),+        "action": args.get("action"),+        "objectkind": args.get("objectkind"),+        "medium": args.get("medium"),+        "project": args.get("project"),+        "expect": args.get("expect"),+    }+    # These two are meaningful ONLY as the literal string "false" (the script tests+    # for it), so a Python False must not collapse to None and get dropped.+    for key in ("reload", "disable_dialog"):+        if key in args:+            params[key] = "false" if args[key] in (False, "false") else "true"+    return addin_call("run_outjob", params, timeout=args.get("timeout", 300))++ COMMAND_HANDLERS = {     "altium_ping": handle_ping,     "altium_detect": handle_detect,+    "altium_script_state": handle_script_state,+    "altium_export_step": handle_export_step,+    "altium_run_outjob": handle_run_outjob,     "altium_compile_intlib": handle_compile_intlib,     "altium_extract_intlib": handle_extract_intlib,     "altium_cleanup_libs": handle_cleanup_libs,@@ -264,6 +362,8 @@ COMMAND_HANDLERS = {     "altium_import_part": handle_import_part,     "altium_add_body": handle_add_body,     "altium_set_visibility": handle_set_visibility,+    "altium_copy_sch_component": handle_copy_sch_component,+    "altium_copy_footprint": handle_copy_footprint, }  
bridge-src/test_addin_client.pyadded+117
@@ -0,0 +1,117 @@+#!/usr/bin/env python3+"""Regression tests for the script sync in addin_client.++The bug these exist to prevent: _ensure_scripts() used to copy the packaged+AdomAltium.pas over the exchange copy unconditionally, on EVERY verb call. Since+the exchange dir is also where a script gets hand-deployed while developing, a+work-in-progress script was destroyed by the next bridge call, and the only way to+test an edit was to bypass the bridge and drive X2.EXE directly.++Runs on Linux (the container) — altium_detect guards its winreg import, and these+tests never reach Altium.++Usage:  python3 -m unittest test_addin_client -v+"""++import os+import sys+import tempfile+import time+import unittest+from pathlib import Path++sys.path.insert(0, str(Path(__file__).parent))++import addin_client+++class ScriptSyncTest(unittest.TestCase):+    def setUp(self):+        self.tmp = tempfile.TemporaryDirectory()+        root = Path(self.tmp.name)++        self.packaged = root / "packaged"+        (self.packaged).mkdir()+        (self.packaged / "AdomAltium.pas").write_text("PACKAGED")+        (self.packaged / "AdomAltium.PrjScr").write_text("PRJ")++        self.exchange = root / "exchange"++        self._saved = (addin_client.ADDIN_SRC, addin_client.EXCHANGE_DIR)+        addin_client.ADDIN_SRC = self.packaged+        addin_client.EXCHANGE_DIR = str(self.exchange)+        addin_client._SYNC_NOTES.clear()+        os.environ.pop(addin_client.NO_SYNC_ENV, None)++    def tearDown(self):+        addin_client.ADDIN_SRC, addin_client.EXCHANGE_DIR = self._saved+        os.environ.pop(addin_client.NO_SYNC_ENV, None)+        self.tmp.cleanup()++    def live_pas(self):+        return (self.exchange / "AdomAltium.pas").read_text()++    def write_live(self, text, newer_than_packaged):+        """Put a script in the exchange dir, older or newer than the packaged one."""+        self.exchange.mkdir(exist_ok=True)+        p = self.exchange / "AdomAltium.pas"+        p.write_text(text)+        base = (self.packaged / "AdomAltium.pas").stat().st_mtime+        stamp = base + 60 if newer_than_packaged else base - 60+        os.utime(p, (stamp, stamp))++    # --- the actual behaviours -------------------------------------------------++    def test_missing_destination_is_populated(self):+        addin_client._ensure_scripts()+        self.assertEqual(self.live_pas(), "PACKAGED")+        self.assertEqual(addin_client._SYNC_NOTES["AdomAltium.pas"], "synced")++    def test_identical_destination_is_not_rewritten(self):+        addin_client._ensure_scripts()+        before = (self.exchange / "AdomAltium.pas").stat().st_mtime_ns+        time.sleep(0.01)+        addin_client._ensure_scripts()+        self.assertEqual(addin_client._SYNC_NOTES["AdomAltium.pas"], "same")+        self.assertEqual((self.exchange / "AdomAltium.pas").stat().st_mtime_ns, before)++    def test_newer_hand_deployed_script_survives(self):+        """THE regression guard: a dev script in the exchange dir is never clobbered."""+        self.write_live("DEV WORK IN PROGRESS", newer_than_packaged=True)+        for _ in range(3):  # every verb call used to overwrite it+            addin_client._ensure_scripts()+        self.assertEqual(self.live_pas(), "DEV WORK IN PROGRESS")+        self.assertEqual(addin_client._SYNC_NOTES["AdomAltium.pas"], "kept-newer")++    def test_older_destination_is_upgraded(self):+        """A normal package upgrade must still overwrite a stale exchange copy."""+        self.write_live("STALE", newer_than_packaged=False)+        addin_client._ensure_scripts()+        self.assertEqual(self.live_pas(), "PACKAGED")+        self.assertEqual(addin_client._SYNC_NOTES["AdomAltium.pas"], "synced")++    def test_env_var_disables_sync_entirely(self):+        self.write_live("UNTOUCHABLE", newer_than_packaged=False)+        os.environ[addin_client.NO_SYNC_ENV] = "1"+        addin_client._ensure_scripts()+        self.assertEqual(self.live_pas(), "UNTOUCHABLE")+        self.assertEqual(addin_client._SYNC_NOTES["AdomAltium.pas"], "skipped-env")++    def test_script_state_reports_dev_override(self):+        self.write_live("DEV", newer_than_packaged=True)+        addin_client._ensure_scripts()+        state = addin_client.script_state()+        self.assertTrue(state["devOverride"])+        entry = state["scripts"]["AdomAltium.pas"]+        self.assertNotEqual(entry["liveSha256"], entry["packagedSha256"])++    def test_script_state_clean_when_in_sync(self):+        addin_client._ensure_scripts()+        state = addin_client.script_state()+        self.assertFalse(state["devOverride"])+        entry = state["scripts"]["AdomAltium.pas"]+        self.assertEqual(entry["liveSha256"], entry["packagedSha256"])+++if __name__ == "__main__":+    unittest.main()
install.sh+1−1
@@ -5,4 +5,4 @@ mkdir -p "$HOME/.claude/skills/adom-desktop-altium-bridge" cp -f "$H/SKILL.md" "$HOME/.claude/skills/adom-desktop-altium-bridge/SKILL.md" echo "[adom-desktop-altium-bridge] skill installed." echo "Install the bridge onto your Windows desktop with:"-echo "  adom-desktop bridge_install '{\"manifestUrl\":\"https://wiki.adom.inc/static/apps/adom-desktop-altium-bridge/altium-bridge-manifest.json\"}'"+echo "  adom-desktop bridge_install '{\"manifestUrl\":\"https://wiki.adom.inc/download/barrett-land/adom-desktop-altium-bridge/latest/altium-bridge-manifest.json\"}'"
package.json+4−4
@@ -1,8 +1,8 @@ {   "slug": "adom-desktop-altium-bridge",-  "version": "0.0.36",+  "version": "0.0.37",   "type": "app",-  "description": "Adom Desktop bridge for Altium Designer — add LCSC/JLCPCB-tagged parts to file-based libraries (symbols, footprints with 3D, value-only display) by driving Altium via DelphiScript.",+  "description": "Adom Desktop bridge for Altium Designer \u2014 add LCSC/JLCPCB-tagged parts to file-based libraries (symbols, footprints with 3D, value-only display) by driving Altium via DelphiScript.",   "tags": [     "app",     "adom-desktop",@@ -19,7 +19,7 @@   "needs_sudo": false,   "hero": {     "headline": "Altium Bridge for Adom Desktop",-    "subhead": "Drive Altium Designer from cloud Claude — add LCSC-tagged parts to your libraries",+    "subhead": "Drive Altium Designer from cloud Claude \u2014 add LCSC-tagged parts to your libraries",     "screenshot": "docs/hero.png"   }-}\ No newline at end of file+}