app
Adom Desktop - KiCad Bridge
Public Made by Adomby adom
Reference implementation of the KiCad bridge — multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
"""Unified CLI for the Gallia KiCad tour — used by Claude Code.
Subcommands:
preflight Check all prerequisites
screenshot [path] Capture desktop to PNG
highlight x y w h Show highlight overlay (non-blocking)
bridge <cmd> [k=v ...] Send bridge command, print JSON
ipc-test [--section N] Run IPC test suite
ipc-layer <id> Set active PCB layer via IPC
window <action> <title> Window management (find/close/front/position/wait)
Zero pip deps — only stdlib.
"""
import argparse
import json
import os
import subprocess
import sys
import time
# Ensure tour package is importable
TOUR_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, TOUR_DIR)
PYTHON = sys.executable
# ═══════════════════════════════════════════════════════════════════
# Subcommands
# ═══════════════════════════════════════════════════════════════════
def cmd_preflight(_args):
"""Check all tour prerequisites."""
from bridge_client import health_check
from ipc_client import has_test_binary, has_layer_binary
import win_manager
print("=== Gallia Tour Preflight ===\n")
# Bridge server
health = health_check()
status = "ONLINE" if health else "OFFLINE"
print(f"Bridge server: {status}")
if health:
print(f" URL: http://127.0.0.1:8772")
# IPC binaries
print(f"IPC test binary: {'FOUND' if has_test_binary() else 'MISSING'}")
print(f"IPC layer binary: {'FOUND' if has_layer_binary() else 'MISSING'}")
# Screen size
sw, sh = win_manager.get_screen_size()
print(f"Screen size: {sw} x {sh}")
# KiCad windows
found_kicad = False
for name in ["KiCad", "Schematic Editor", "PCB Editor",
"Symbol Editor", "Footprint Editor"]:
wins = win_manager.find_windows_by_title(name)
if wins:
title_ascii = wins[0][1].encode("ascii", "replace").decode()
print(f"Window [{name}]: OPEN - {title_ascii}")
found_kicad = True
if not found_kicad:
print("KiCad windows: none open")
# Demo project
demo = os.path.join(TOUR_DIR, "demo-project")
sch = os.path.join(demo, "demo-project.kicad_sch")
pcb = os.path.join(demo, "demo-project.kicad_pcb")
print(f"\nDemo schematic: {'EXISTS' if os.path.exists(sch) else 'MISSING'}")
print(f"Demo PCB: {'EXISTS' if os.path.exists(pcb) else 'MISSING'}")
print(f"Demo dir: {demo}")
# Print paths for Claude to use
if os.path.exists(sch):
print(f"\nSCH_PATH = {sch}")
if os.path.exists(pcb):
print(f"PCB_PATH = {pcb}")
def cmd_screenshot(args):
"""Capture the desktop to a PNG file."""
from screenshot import capture_screen
path = args.output
if not path:
temp = os.environ.get("TEMP", os.environ.get("TMP", "."))
path = os.path.join(temp, "gallia_tour_screenshot.png")
capture_screen(path)
# Print path so Claude can read it
print(path)
def cmd_highlight(args):
"""Show a highlight overlay. Spawns a separate process (non-blocking)."""
script = os.path.join(TOUR_DIR, "highlight.py")
cmd = [
PYTHON, script,
str(args.x), str(args.y), str(args.width), str(args.height),
]
if args.color:
cmd.extend(["--color", args.color])
if args.duration:
cmd.extend(["--duration", str(args.duration)])
if args.label:
cmd.extend(["--label", args.label])
if args.thickness:
cmd.extend(["--thickness", str(args.thickness)])
# Spawn without a console window on Windows
flags = 0
if os.name == "nt":
flags = subprocess.CREATE_NO_WINDOW
subprocess.Popen(cmd, creationflags=flags)
print(f"Highlight: ({args.x}, {args.y}) {args.width}x{args.height}"
f" color={args.color} duration={args.duration}ms"
+ (f' label="{args.label}"' if args.label else ""))
def cmd_bridge(args):
"""Send a command to the KiCad bridge server."""
from bridge_client import send_command, health_check
if args.command == "health":
result = health_check()
if result:
print(json.dumps(result, indent=2))
else:
print('{"status": "offline"}')
return
# Parse key=value argument pairs
cmd_args = {}
for item in (args.args or []):
key, _, val = item.partition("=")
if not val:
print(f"Warning: skipping malformed arg '{item}' (expected key=value)")
continue
cmd_args[key] = val
result = send_command(args.command, cmd_args)
print(json.dumps(result, indent=2, default=str))
def cmd_ipc_test(args):
"""Run the IPC test suite and print output."""
from ipc_client import run_ipc_test, has_test_binary
if not has_test_binary():
print("ERROR: test_kicad_ipc.exe not found")
print("Build with: cargo build --release --example test_kicad_ipc")
sys.exit(1)
sections = run_ipc_test()
if args.section is not None:
text = sections.get(args.section)
if text:
print(text)
else:
print(f"Section [{args.section}] not found in output")
print(f"Available sections: {sorted(sections.keys())}")
else:
for k in sorted(sections.keys()):
print(sections[k])
print()
def cmd_ipc_layer(args):
"""Set KiCad's active PCB layer via IPC."""
from ipc_client import set_active_layer, has_layer_binary
if not has_layer_binary():
print("ERROR: ipc_set_layer.exe not found")
print("Build with: cargo build --release --example ipc_set_layer")
sys.exit(1)
# Layer name map for user-friendly output
layer_names = {3: "F.Cu", 4: "In1.Cu", 5: "In2.Cu", 34: "B.Cu"}
name = layer_names.get(args.layer_id, f"layer {args.layer_id}")
result = set_active_layer(args.layer_id)
if result.get("success"):
print(f"Active layer set to: {name} (id={args.layer_id})")
if result.get("output"):
print(result["output"])
else:
print(f"ERROR: Failed to set layer to {name}")
print(result.get("output", "Unknown error"))
sys.exit(1)
def cmd_window(args):
"""Window management: find, close, front, position, wait."""
import win_manager
if args.action == "find":
windows = win_manager.find_windows_by_title(args.title)
if windows:
for hwnd, title in windows:
safe = title.encode("ascii", "replace").decode()
print(f" hwnd={hwnd} title={safe}")
else:
print(f"No windows matching '{args.title}'")
elif args.action == "close":
ok = win_manager.close_window_by_title(args.title)
print("Closed" if ok else f"Window not found: '{args.title}'")
elif args.action == "front":
windows = win_manager.find_windows_by_title(args.title)
if windows:
win_manager.bring_to_front(windows[0][0])
safe = windows[0][1].encode("ascii", "replace").decode()
print(f"Brought to front: {safe}")
else:
print(f"Window not found: '{args.title}'")
elif args.action == "position":
if args.x is None or args.y is None or args.w is None or args.h is None:
print("ERROR: position requires x y w h arguments")
sys.exit(1)
windows = win_manager.find_windows_by_title(args.title)
if windows:
win_manager.position_window(windows[0][0], args.x, args.y,
args.w, args.h)
safe = windows[0][1].encode("ascii", "replace").decode()
print(f"Positioned: {safe} at ({args.x},{args.y}) "
f"{args.w}x{args.h}")
else:
print(f"Window not found: '{args.title}'")
elif args.action == "wait":
timeout = args.timeout or 15
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
windows = win_manager.find_windows_by_title(args.title)
if windows:
safe = windows[0][1].encode("ascii", "replace").decode()
print(f"Found: {safe}")
return
time.sleep(0.5)
print(f"Timeout ({timeout}s) waiting for: '{args.title}'")
sys.exit(1)
elif args.action == "screen-size":
sw, sh = win_manager.get_screen_size()
print(f"{sw} {sh}")
def cmd_panel(args):
"""Control the narration side panel."""
import urllib.request
port = args.port or 8799
base = f"http://127.0.0.1:{port}"
if args.action == "start":
# Launch narration.py as a background process
script = os.path.join(TOUR_DIR, "narration.py")
cmd = [PYTHON, script, "--port", str(port)]
# On Windows, use `pythonw.exe` (windowless Python) to launch
# the tkinter GUI without a console window. Falls back to
# regular python.exe if pythonw doesn't exist.
pythonw = PYTHON.replace("python.exe", "pythonw.exe")
if os.path.exists(pythonw):
cmd[0] = pythonw
proc = subprocess.Popen(cmd)
# Wait for the HTTP server to come up
for _ in range(30):
try:
req = urllib.request.Request(f"{base}/health")
with urllib.request.urlopen(req, timeout=1) as resp:
if resp.status == 200:
print(f"Panel started (pid={proc.pid}, port={port})")
return
except Exception:
time.sleep(0.3)
print(f"Panel process started (pid={proc.pid}) but health check timed out")
elif args.action == "update":
payload = {}
if args.step is not None:
payload["step"] = args.step
if args.total is not None:
payload["total"] = args.total
if args.title:
payload["title"] = args.title
if args.description:
payload["description"] = args.description.replace("\\n", "\n")
if args.act:
payload["act"] = args.act
if args.status:
payload["status"] = args.status
if args.status_color:
payload["status_color"] = args.status_color
if args.data:
# Convert literal \n sequences to actual newlines
payload["data"] = args.data.replace("\\n", "\n")
if args.data_color:
payload["data_color"] = args.data_color
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
f"{base}/update", data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=5):
print("Panel updated")
except Exception as e:
print(f"Panel update failed: {e}")
elif args.action == "stop":
req = urllib.request.Request(f"{base}/quit", method="POST",
data=b"{}")
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=5):
print("Panel stopped")
except Exception:
print("Panel may already be stopped")
elif args.action == "health":
try:
req = urllib.request.Request(f"{base}/health")
with urllib.request.urlopen(req, timeout=3) as resp:
print(json.loads(resp.read().decode()))
except Exception:
print("Panel not running")
# ═══════════════════════════════════════════════════════════════════
# Argument parser
# ═══════════════════════════════════════════════════════════════════
def build_parser():
parser = argparse.ArgumentParser(
description="Gallia KiCad Tour CLI — used by Claude Code",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
sub = parser.add_subparsers(dest="cmd")
# preflight
sub.add_parser("preflight", help="Check all prerequisites")
# screenshot
p = sub.add_parser("screenshot", help="Capture desktop screenshot")
p.add_argument("output", nargs="?", help="Output PNG path")
# highlight
p = sub.add_parser("highlight", help="Show highlight overlay on screen")
p.add_argument("x", type=int, help="X position")
p.add_argument("y", type=int, help="Y position")
p.add_argument("width", type=int, help="Width of highlight")
p.add_argument("height", type=int, help="Height of highlight")
p.add_argument("--color", default="#00b8b1", help="Border color")
p.add_argument("--duration", type=int, default=5000, help="Duration ms")
p.add_argument("--label", default="", help="Label text")
p.add_argument("--thickness", type=int, default=3, help="Border px")
# bridge
p = sub.add_parser("bridge", help="Send bridge command")
p.add_argument("command", help="Command name (or 'health')")
p.add_argument("args", nargs="*", help="key=value pairs")
# ipc-test
p = sub.add_parser("ipc-test", help="Run IPC test suite")
p.add_argument("--section", type=int, help="Show only section N")
# ipc-layer
p = sub.add_parser("ipc-layer", help="Set active PCB layer via IPC")
p.add_argument("layer_id", type=int,
help="Layer ID (F.Cu=3, In1.Cu=4, In2.Cu=5, B.Cu=34)")
# window
p = sub.add_parser("window", help="Window management")
p.add_argument("action",
choices=["find", "close", "front", "position", "wait",
"screen-size"],
help="Action to perform")
p.add_argument("title", nargs="?", default="",
help="Window title substring")
p.add_argument("x", type=int, nargs="?", help="X position")
p.add_argument("y", type=int, nargs="?", help="Y position")
p.add_argument("w", type=int, nargs="?", help="Width")
p.add_argument("h", type=int, nargs="?", help="Height")
p.add_argument("--timeout", type=int, default=15,
help="Wait timeout in seconds")
# panel
p = sub.add_parser("panel", help="Control the narration side panel")
p.add_argument("action", choices=["start", "update", "stop", "health"],
help="Panel action")
p.add_argument("--port", type=int, default=8799, help="Panel HTTP port")
p.add_argument("--step", type=int, help="Current step number")
p.add_argument("--total", type=int, help="Total steps")
p.add_argument("--title", help="Step title")
p.add_argument("--description", help="Step description / narration")
p.add_argument("--act", help="Act name")
p.add_argument("--status", help="Status text")
p.add_argument("--status-color", dest="status_color", help="Status color")
p.add_argument("--data", help="Data area content")
p.add_argument("--data-color", dest="data_color", help="Data area color")
return parser
def main():
parser = build_parser()
args = parser.parse_args()
handlers = {
"preflight": cmd_preflight,
"screenshot": cmd_screenshot,
"highlight": cmd_highlight,
"bridge": cmd_bridge,
"ipc-test": cmd_ipc_test,
"ipc-layer": cmd_ipc_layer,
"window": cmd_window,
"panel": cmd_panel,
}
handler = handlers.get(args.cmd)
if handler:
handler(args)
else:
parser.print_help()
if __name__ == "__main__":
main()