main
Colby Knox AI Flow 0.2.65: unquoted wiki symbols, current verified list, macOS desktop stand-in (#196 #192 #195) fd77b00
#!/usr/bin/env bash
# kicad-cli-remote: a native KiCad 10 `kicad-cli` for a container that has none, run on a desktop
# through Adom Bridge. Implements what adom-aiflow needs (ADOM_AIFLOW_KICAD_CLI):
#   kicad-cli-remote --version                (this wrapper)
#   kicad-cli-remote version                  (the desktop's kicad-cli answers)
#   kicad-cli-remote pcb drc --format json --output <report.json> [--refill-zones] [--schematic-parity] <board.kicad_pcb>
#   kicad-cli-remote sch erc --format json --output <report.json> [--severity-all] <design.kicad_sch>
#   kicad-cli-remote sch export netlist --format kicadsexpr --output <out.net> <design.kicad_sch>
#   kicad-cli-remote pcb export gerbers|drill --output <dir>/ [flags] <board.kicad_pcb>
#   kicad-cli-remote pcb export pos --output <file.csv> [flags] <board.kicad_pcb>
#   kicad-cli-remote pcb export step --output <file.step> [flags] <board.kicad_pcb>  (native output passed through)
# The board travels with its sibling .kicad_pro / .kicad_dru so project rules are kept. The board and
# the schematic both travel with the project's sym-lib-table and fp-lib-table and every ${KIPRJMOD}
# library they name (a .pretty footprint folder with all its files), in the same folders relative to
# the project. The schematic travels with its sub-sheets and .kicad_pro. `pcb drc --schematic-parity`
# also sends the board's schematic and its sub-sheets: <board>.kicad_sch beside the board, else
# KICAD_REMOTE_SCHEMATIC (adom-aiflow sets the run's recorded schematic), sent as <board>.kicad_sch.
# Local outputs may be relative: they are resolved against the current folder before anything is pulled.
# Windows desktops run cmd through shell_execute in %TEMP%. Linux desktops (kicad-bridge's Linux build, a
# Flatpak KiCad too) run bash through run_script in ~/.adom-aiflow-kicad (home, not /tmp: a Flatpak has its own /tmp).
# macOS desktops run the same bash, through shell_execute (issue #195), in ~/.adom-aiflow-kicad.
# Env: KICAD_REMOTE_TARGET (adom-aiflow sets the run's desktop; unset: this machine's own desktop),
#      KICAD_REMOTE_CLI (native kicad-cli path on it), KICAD_REMOTE_THREAD (ai-thread name for the bridge;
#      default kicad-cli-remote), KICAD_REMOTE_OS (windows|linux; default: detected),
#      KICAD_REMOTE_TMP (job folder parent on the desktop),
#      KICAD_REMOTE_SCHEMATIC (the root .kicad_sch for `pcb drc --schematic-parity` when none is beside the board).
set -euo pipefail
T="${KICAD_REMOTE_TARGET:-}"
TH="${KICAD_REMOTE_THREAD:-kicad-cli-remote}"
# The desktop's native kicad-cli: KICAD_REMOTE_CLI, else what the KiCad bridge reports (install.kicadCli).
CLI="${KICAD_REMOTE_CLI:-}"
ROS="${KICAD_REMOTE_OS:-}"; ROS="${ROS,,}"; KSTATUS_OS=""; RTMP=""; RTMP_OURS=""

if [[ "${1:-}" == "--version" ]]; then echo "kicad-cli-remote -> ${T:-this machine} (native KiCad via Adom Bridge)"; exit 0; fi

ab() { adom-bridge ${T:+--target "$T"} "$1" "$2" --ai-thread "$TH"; }
jstdout() { python3 -c 'import sys,json
try: print(json.load(sys.stdin).get("stdout",""))
except Exception: pass'; }
# A local path made absolute (pull_file's saveTo and the existence checks must agree on it).
absp() { realpath -m -- "$1"; }

find_cli() {
  if [[ -z "$CLI" ]]; then
    local found
    found=$(ab kicad_status '{"reason":"find the native kicad-cli for adom-aiflow"}' | python3 -c 'import sys,json
d=json.load(sys.stdin);print((d.get("install") or {}).get("kicadCli",""));print(str((d.get("platform") or {}).get("os") or ""))')
    CLI="$(sed -n 1p <<<"$found")"; KSTATUS_OS="$(sed -n 2p <<<"$found")"
    [[ -n "$CLI" ]] || { echo "kicad-cli-remote: no KiCad found on ${T:-this machine} (set KICAD_REMOTE_CLI)" >&2; exit 2; }
  fi
  find_os
}
# The desktop's OS: KICAD_REMOTE_OS, else kicad_status platform.os (kicad-bridge 1.0.56+), else
# `adom-bridge targets` os, else the shape of the kicad-cli path.
find_os() {
  case "$ROS" in linux|mac|windows) return 0;; esac
  local o="${ROS:-${KSTATUS_OS,,}}"
  if [[ -z "$o" && -n "$T" ]]; then
    o=$(adom-bridge targets --ai-thread "$TH" 2>/dev/null | python3 -c 'import sys,json
t=sys.argv[1].lower()
try: d=json.load(sys.stdin)
except Exception: d={}
for x in d.get("targets") or []:
    if t in (str(x.get("name") or "").lower(), str(x.get("hostname") or "").lower()) and x.get("os"): print(str(x["os"]).lower()); break' "$T" || true)
  fi
  case "$o" in
    *linux*) ROS=linux;;
    *darwin*|*macos*|*mac\ os*|osx|mac) ROS=mac;;
    *windows*|win*) ROS=windows;;
    *) if [[ "$CLI" == /Applications/* ]]; then ROS=mac; elif [[ "$CLI" == /* ]]; then ROS=linux; else ROS=windows; fi;;
  esac
}
# Linux and macOS desktops take bash; Windows takes cmd lines.
posix() { [[ "$ROS" != windows ]]; }
# Linux: run a bash script on the desktop (run_script; no shell-string quoting), print the bridge reply.
# macOS (issue #195): Adom Bridge's run_script there answers "No command specified" to a scriptB64, so the
# script goes through shell_execute as base64 piped into bash (still no shell-string quoting of the script).
lx_script() {
  if [[ "$ROS" == mac ]]; then
    ab shell_execute "$(python3 -c 'import json,sys,base64;print(json.dumps({"command":"echo "+base64.b64encode(sys.argv[1].encode()).decode()+" | base64 -d | bash","timeoutSeconds":int(sys.argv[2]),"reason":sys.argv[3]}))' "$1" "$2" "$3")"
    return
  fi
  ab run_script "$(python3 -c 'import json,sys,base64;print(json.dumps({"interpreter":"bash","scriptB64":base64.b64encode(sys.argv[1].encode()).decode(),"timeoutSeconds":int(sys.argv[2]),"reason":sys.argv[3]}))' "$1" "$2" "$3")"
}
# Linux: one command line, every argument quoted for bash.
lx_cmd() { printf '%q ' "$@"; }
find_tmp() {
  # The job folder parent: KICAD_REMOTE_TMP, else %TEMP% (Windows) or ~/.adom-aiflow-kicad (Linux).
  RTMP="${KICAD_REMOTE_TMP:-}"
  [[ -n "$RTMP" ]] && return 0
  if posix; then
    RTMP_OURS=1
    RTMP=$(lx_script 'mkdir -p "$HOME/.adom-aiflow-kicad" && printf %s "$HOME/.adom-aiflow-kicad"' 30 "job folder for an adom-aiflow kicad-cli job" | jstdout | tr -d '\r\n')
    [[ "$RTMP" == /* ]] || { echo "kicad-cli-remote: could not make a job folder in the home folder on ${T:-this machine}" >&2; exit 2; }
  else
    RTMP=$(ab shell_execute '{"command":"echo %TEMP%","reason":"temp folder for an adom-aiflow kicad-cli job"}' | jstdout | tr -d '\r\n' | tr '\\' /)
    [[ -n "$RTMP" ]] || { echo "kicad-cli-remote: no temp folder on ${T:-this machine}" >&2; exit 2; }
  fi
}
cleanup_remote() {
  if posix; then
    # only a job folder, and only inside the job parent
    [[ "$1" == "$RTMP"/aiflow-* ]] && lx_script "rm -rf -- $(printf '%q' "$1")${RTMP_OURS:+; rmdir -- $(printf '%q' "$RTMP") 2>/dev/null}; true" 60 "clean up the kicad-cli job folder" >/dev/null || true
    return 0
  fi
  ab shell_execute "$(python3 -c 'import json,sys;print(json.dumps({"command":"rmdir /s /q \""+sys.argv[1].replace("/","\\")+"\"","reason":"clean up the kicad-cli temp folder"}))' "$1")" >/dev/null || true
}
# Send local files to one remote folder: <dest> <files...>
send_to() {
  local d="$1"; shift
  local reply moves
  reply=$(ab send_files "$(python3 -c 'import json,sys;print(json.dumps({"filePaths":sys.argv[2:],"dest":sys.argv[1],"reason":"files for a kicad-cli job for adom-aiflow (native KiCad on the desktop)"}))' "$d" "$@")")
  posix || return 0
  # issue #195: Adom Bridge on macOS saves into ~/Downloads whatever dest says, and its destinationPaths
  # say where; move each file that landed elsewhere into the job folder
  moves=$(python3 -c 'import json,os,shlex,sys
d=sys.argv[1].rstrip("/")
try: r=json.loads(sys.argv[2])
except Exception: r={}
for p in r.get("destinationPaths") or []:
    p=str(p)
    if os.path.dirname(p.rstrip("/")) != d: print("mv -f -- %s %s" % (shlex.quote(p), shlex.quote(d + "/" + os.path.basename(p.rstrip("/")))))' "$d" "$reply")
  [[ -n "$moves" ]] || return 0
  lx_script "mkdir -p $(printf '%q' "$d") && $(tr '\n' ';' <<<"$moves")" 120 "move the sent files into the kicad-cli job folder" >/dev/null
}
# The project files a kicad-cli job needs, grouped by folder relative to the job folder, one
# "<sub>\t<json list>" line per folder. Modes:
#   sch <root.kicad_sch>                 the hierarchy, its .kicad_pro, the lib tables and their libraries
#   board <board.kicad_pcb> [sch] [stage] the board, .kicad_pro, .kicad_dru, the lib tables and their
#                                        libraries, and with a schematic its hierarchy, the root sent as
#                                        <board>.kicad_sch (KiCad's parity check reads that name; a copy
#                                        under that name is made in <stage> when the names differ)
# Library tables: sym-lib-table and fp-lib-table both, and every ${KIPRJMOD} URI they name, a file or a
# folder (a .pretty footprint library: all its files, in their folders).
PROJECT_FILES_PY=$(cat <<'PY'
import json, os, re, shutil, sys
mode, main = sys.argv[1], os.path.realpath(sys.argv[2])
sch = os.path.realpath(sys.argv[3]) if len(sys.argv) > 3 and sys.argv[3] else ""
stage = sys.argv[4] if len(sys.argv) > 4 else ""
groups = {}
def add(path, base, sub=None):
    if not os.path.isfile(path): return
    if sub is None:
        rel = os.path.relpath(os.path.dirname(path), base)
        sub = "" if rel == "." or rel.startswith("..") else rel.replace(os.sep, "/")
    groups.setdefault(sub, set()).add(path)
def hierarchy(root):
    seen, todo = [], [root]
    while todo:
        s = todo.pop()
        if s in seen or not os.path.isfile(s): continue
        seen.append(s)
        text = open(s, encoding="utf-8", errors="replace").read()
        for m in re.finditer(r'\(property\s+"Sheet ?[Ff]ile(?:name)?"\s+"([^"]+)"', text):
            todo.append(os.path.normpath(os.path.join(os.path.dirname(s), m.group(1))))
    return seen
def lib_tables(proj):
    for name in ("sym-lib-table", "fp-lib-table"):
        table = os.path.join(proj, name)
        if not os.path.isfile(table): continue
        add(table, proj)
        for uri in re.findall(r'\(uri\s+"([^"]+)"\)', open(table, encoding="utf-8", errors="replace").read()):
            if "${KIPRJMOD}" not in uri: continue
            p = os.path.normpath(uri.replace("${KIPRJMOD}", proj))
            if os.path.isfile(p): add(p, proj)
            elif os.path.isdir(p):
                for d, _, names in os.walk(p):
                    for n in sorted(names): add(os.path.join(d, n), proj)
if mode == "sch":
    proj = os.path.dirname(main)
    for f in hierarchy(main): add(f, proj)
    add(os.path.splitext(main)[0] + ".kicad_pro", proj)
    lib_tables(proj)
else:
    proj = os.path.dirname(main); stem = os.path.splitext(main)[0]
    add(main, proj)
    for ext in (".kicad_pro", ".kicad_dru"): add(stem + ext, proj)
    lib_tables(proj)
    if sch:
        sproj = os.path.dirname(sch)
        # a board snapshot beside no lib tables: the schematic's project has them
        if not any(os.path.isfile(os.path.join(proj, n)) for n in ("sym-lib-table", "fp-lib-table")): lib_tables(sproj)
        want = os.path.basename(stem) + ".kicad_sch"
        for f in hierarchy(sch):
            if f == sch:
                if os.path.basename(sch) != want:
                    os.makedirs(stage, exist_ok=True); f = os.path.join(stage, want); shutil.copyfile(sch, f)
                add(f, sproj, "")
            else: add(f, sproj)
for k in sorted(groups): print(k + "\t" + json.dumps(sorted(groups[k])))
PY
)
# Send grouped files ("<sub>\t<json list>" lines on stdin) into <dest>/<sub>.
send_groups() {
  local dest="$1" line sub list d files
  while IFS= read -r line; do
    [[ -n "$line" ]] || continue
    sub="${line%%$'\t'*}"; list="${line#*$'\t'}"
    d="$dest"; [[ -n "$sub" ]] && d="$dest/$sub"
    mapfile -t files < <(python3 -c 'import json,sys;[print(f) for f in json.loads(sys.argv[1])]' "$list")
    [[ ${#files[@]} -gt 0 ]] && send_to "$d" "${files[@]}"
  done
}
# The board, its project files, lib tables and ${KIPRJMOD} libraries into <dest>; with a schematic
# (<board> <dest> <root.kicad_sch>) its hierarchy too, the root under the board's name.
send_board() {
  local board="$1" dest="$2" sch="${3:-}" stage groups
  stage="$(mktemp -d)"
  groups=$(python3 -c "$PROJECT_FILES_PY" board "$board" "$sch" "$stage")
  send_groups "$dest" <<<"$groups"
  rm -rf "$stage"
}
# The schematic hierarchy, project, lib tables and ${KIPRJMOD} libraries into <dest>, in the same
# folders relative to the project.
send_schematic() {
  local groups
  groups=$(python3 -c "$PROJECT_FILES_PY" sch "$1")
  send_groups "$2" <<<"$groups"
}
# Run the desktop's kicad-cli: <timeout> <reason> <mkdir-or-empty> <args...>. Remote paths use forward
# slashes; Windows gets backslashes and quotes, Linux gets bash quoting. Prints the bridge reply.
RENV=()   # NAME=value pairs set on the desktop for the next run_cli (issue #177: the 3rd-party model folder)
run_cli() {
  local to="$1" why="$2" mk="$3"; shift 3
  if posix; then
    local envp=""; [[ ${#RENV[@]} -gt 0 ]] && envp="env $(lx_cmd "${RENV[@]}")"
    lx_script "${mk:+mkdir -p $(printf '%q' "$mk") && }${envp}$(lx_cmd "$CLI" "$@")" "$to" "$why"
  else
    # every path quoted; a folder output keeps its trailing separator, doubled before the closing quote so
    # Windows does not read \" as a quote
    ab shell_execute "$(RENV_JSON="$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1:]))' "${RENV[@]}")" python3 - "$to" "$why" "$mk" "$CLI" "$@" <<'PY'
import json, os, sys
to, why, mk, cli, args = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5:]
def q(a):
    a = a.replace("/", "\\")
    if "\\" in a or " " in a or ":" in a:
        return '"' + (a + "\\" if a.endswith("\\") else a) + '"'
    return a
c = q(cli) + " " + " ".join(q(a) for a in args)
for kv in json.loads(os.environ.get("RENV_JSON") or "[]"):
    k, _, v = kv.partition("=")
    c = 'set "' + k + '=' + v.replace("/", "\\") + '" && ' + c
if mk: c = 'mkdir ' + q(mk) + ' 2>nul & ' + c
print(json.dumps({"command": c, "timeoutSeconds": int(to), "reason": why}))
PY
)"
  fi
}
# The names in a remote folder, one per line.
list_remote() {
  if posix; then
    lx_script "cd $(printf '%q' "$1") && ls -1A" 60 "list the kicad-cli outputs" | jstdout
  else
    ab shell_execute "$(python3 -c 'import json,sys;print(json.dumps({"command":"dir /b \""+sys.argv[1].replace("/","\\")+"\"","reason":"list the kicad-cli outputs"}))' "$1")" | jstdout
  fi | tr -d '\r' | sed '/^[[:space:]]*$/d'
}
# Pull remote files into an absolute local folder: <saveTo> <remote paths...>. Linux: a file pull_file did
# not land (a mesh-only box the relay does not hold answers target_not_found) comes back in ranged
# read_file pieces, checked against the desktop's sha256sum.
pull_to() {
  local save; save="$(absp "$1")"; shift
  mkdir -p "$save"
  ab pull_file "$(python3 -c 'import json,sys;print(json.dumps({"filePaths":sys.argv[2:],"saveTo":sys.argv[1],"reason":"bring the kicad-cli outputs back"}))' "$save" "$@")" >/dev/null 2>&1 || true
  posix || return 0
  local miss=() p sums
  for p in "$@"; do [[ -f "$save/$(basename "$p")" ]] || miss+=("$p"); done
  [[ ${#miss[@]} -eq 0 ]] && return 0
  sums=$(lx_script "{ sha256sum -- $(printf '%q ' "${miss[@]}") 2>/dev/null || shasum -a 256 -- $(printf '%q ' "${miss[@]}"); }" 60 "checksums of the kicad-cli outputs" | jstdout || true)
  python3 - "$T" "$TH" "$save" "$sums" "${miss[@]}" <<'PY'
import base64, hashlib, json, os, subprocess, sys
target, thread, save, sums, paths = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5:]
want = {}
for line in sums.splitlines():
    h, _, name = line.partition("  ")
    if name: want[name.strip()] = h.strip()
for p in paths:
    data, off = b"", 0
    while True:
        args = json.dumps({"path": p, "encoding": "base64", "offset": off, "length": 1048576, "reason": "bring a kicad-cli output back"})
        cmd = ["adom-bridge"] + (["--target", target] if target else []) + ["read_file", args, "--ai-thread", thread]
        r = subprocess.run(cmd, capture_output=True, text=True)
        try: d = json.loads(r.stdout)
        except Exception: d = {}
        if not d.get("success"): data = None; break
        data += base64.b64decode(d.get("content", ""))
        if d.get("eof") or d.get("nextOffset") is None or d.get("nextOffset") == off: break
        off = d["nextOffset"]
    if data is None or want.get(p) != hashlib.sha256(data).hexdigest(): continue
    tmp = os.path.join(save, "." + os.path.basename(p) + ".part")
    with open(tmp, "wb") as f: f.write(data)
    os.replace(tmp, os.path.join(save, os.path.basename(p)))
PY
}
tail_of() { python3 -c 'import sys,json
try: d=json.load(sys.stdin)
except Exception: sys.exit(0)
c=lambda t,n=800:t if len(t)<=n else t[:560]+" ... "+t[-235:]
print(c(d.get("stdout","")),c(d.get("stderr","")))' >&2 || true; }
new_job() { find_cli; find_tmp; JOB="$RTMP/aiflow-$1-$(date +%s)-$$"; }
# Bring one report back into a local file: <remote file> <local file>. Returns 1 when it did not come back.
fetch_one() {
  local remote="$1" out tmp
  out="$(absp "$2")"; tmp="$(mktemp -d)"
  pull_to "$tmp" "$remote"
  if [[ -f "$tmp/$(basename "$remote")" ]]; then
    mkdir -p "$(dirname "$out")"; mv "$tmp/$(basename "$remote")" "$out"; rmdir "$tmp" 2>/dev/null || true; return 0
  fi
  rm -rf "$tmp"; return 1
}

if [[ "${1:-}" == "version" ]]; then
  # the desktop's own kicad-cli answers (a probe; no files travel)
  find_cli
  v=$(run_cli 60 "kicad-cli version probe for adom-aiflow" "" version | jstdout | tr -d '\r' | sed '/^[[:space:]]*$/d')
  [[ -n "$v" ]] || { echo "kicad-cli-remote: no version came back from ${T:-this machine}" >&2; exit 1; }
  echo "$v"; exit 0
fi

if [[ "${1:-}" == "sch" && ( "${2:-}" == "erc" || ( "${2:-}" == "export" && "${3:-}" == "netlist" ) ) ]]; then
  if [[ "$2" == "erc" ]]; then kind=erc; shift 2; else kind=netlist; shift 3; fi
  if [[ " $* " == *" --help "* || " $* " == *" -h "* ]]; then
    if [[ $kind == erc ]]; then
      echo "Usage: kicad-cli-remote sch erc [--format json|report] --output FILE [--severity-all|--severity-error|--severity-warning] [--units mm|in|mils] INPUT_FILE"
      echo "  Runs the desktop's native KiCad ERC and pulls the report back (default --format json --severity-all)."
    else
      echo "Usage: kicad-cli-remote sch export netlist [--format kicadsexpr] --output FILE INPUT_FILE"
      echo "  --format  kicadsexpr (default), kicadxml, orcadpcb2, ... (passed to the desktop's native KiCad)"
    fi
    exit 0
  fi
  fmt=""; out=""; sch=""; extra=()
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --format) fmt="$2"; shift 2;;
      --output|-o) out="$2"; shift 2;;
      --units) extra+=("$1" "$2"); shift 2;;
      --severity-all|--severity-error|--severity-warning|--severity-exclusions|--exit-code-violations) extra+=("$1"); shift;;
      --*) shift;;
      *) sch="$1"; shift;;
    esac
  done
  [[ -f "$sch" && -n "$out" ]] || { echo "kicad-cli-remote: need --output and an existing schematic" >&2; exit 2; }
  base="$(basename "$sch" .kicad_sch)"
  new_job "$kind"
  send_schematic "$sch" "$JOB"
  rsch="$JOB/$base.kicad_sch"
  if [[ $kind == erc ]]; then
    fmt="${fmt:-json}"
    [[ " ${extra[*]:-} " == *" --severity-"* ]] || extra+=(--severity-all)
    rout="$JOB/$base.erc.$fmt"
    res=$(run_cli 300 "schematic ERC for adom-aiflow" "" sch erc --format "$fmt" "${extra[@]}" --output "$rout" "$rsch") || true
  else
    fmt="${fmt:-kicadsexpr}"
    rout="$JOB/$base.aiflow.net"
    res=$(run_cli 180 "schematic netlist export for adom-aiflow" "" sch export netlist --format "$fmt" --output "$rout" "$rsch") || true
  fi
  if fetch_one "$rout" "$out"; then cleanup_remote "$JOB"; exit 0; fi
  cleanup_remote "$JOB"
  echo "kicad-cli-remote: no $kind report came back" >&2
  echo "$res" | tail_of
  exit 1
fi

if [[ "${1:-}" == "pcb" && "${2:-}" == "export" && "${3:-}" == "step" ]]; then
  # The board twin's STEP: the board, its project files and every ${KIPRJMOD} model travel in their
  # folders; the native output (stdout and stderr, where KiCad prints geometry warnings) is passed
  # through unchanged and the native exit status is kept, so adom-aiflow step-export can classify it.
  shift 3
  if [[ " $* " == *" --help "* || " $* " == *" -h "* ]]; then
    echo "Usage: kicad-cli-remote pcb export step [flags] --output OUT.step INPUT_FILE (flags are passed to the desktop's native KiCad)"
    exit 0
  fi
  out=""; board=""; pass=()
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --output|-o) out="$2"; shift 2;;
      --min-distance|--user-origin|--component-filter|--net-filter|--variant) pass+=("$1" "$2"); shift 2;;
      --*) pass+=("$1"); shift;;
      *) board="$1"; shift;;
    esac
  done
  [[ -f "$board" && -n "$out" ]] || { echo "kicad-cli-remote: need --output and an existing board" >&2; exit 2; }
  base="$(basename "$board" .kicad_pcb)"
  new_job step
  # the board, its project files and every ${KIPRJMOD} model, in their folders relative to the project
  RENV=()
  while IFS= read -r line; do
    if [[ "$line" == ENV$'\t'* ]]; then
      kv="${line#ENV$'\t'}"; RENV+=("${kv%%$'\t'*}=$JOB/${kv#*$'\t'}"); continue
    fi
    sub="${line%%$'\t'*}"; list="${line#*$'\t'}"
    d="$JOB"; [[ -n "$sub" ]] && d="$JOB/$sub"
    mapfile -t files < <(python3 -c 'import json,sys;[print(f) for f in json.loads(sys.argv[1])]' "$list")
    send_to "$d" "${files[@]}"
  done < <(python3 - "$(readlink -f "$board")" <<'PY'
import json, os, re, sys
b = sys.argv[1]; proj = os.path.dirname(b); stem = os.path.splitext(b)[0]
files = {b}
for ext in (".kicad_pro", ".kicad_dru"):
    if os.path.isfile(stem + ext): files.add(stem + ext)
# ${KICAD<N>_3RD_PARTY} set here (step-export points it at the project's kicad/3rdparty) and inside the
# project: those models travel too, and the variable is set to their folder on the desktop (issue #177)
third = {k: os.path.normpath(v) for k, v in os.environ.items() if re.fullmatch(r"KICAD\d+_3RD_PARTY", k) and os.path.isdir(v)}
renv = {}
for m in re.findall(r'\(model\s+"([^"]+)"', open(b, encoding="utf-8", errors="replace").read()):
    p = None
    if "${KIPRJMOD}" in m:
        p = os.path.normpath(m.replace("${KIPRJMOD}", proj))
    for k, v in third.items():
        if "${" + k + "}" in m and not os.path.relpath(v, proj).startswith(".."):
            p = os.path.normpath(m.replace("${" + k + "}", v)); renv[k] = os.path.relpath(v, proj).replace(os.sep, "/")
    if p:
        for c in (p, os.path.splitext(p)[0] + ".step", os.path.splitext(p)[0] + ".stp"):
            if os.path.isfile(c): files.add(c)
for k, rel in renv.items(): print("ENV\t" + k + "\t" + rel)
groups = {}
for f in sorted(files):
    rel = os.path.relpath(os.path.dirname(f), proj)
    if rel.startswith(".."): continue
    groups.setdefault("" if rel == "." else rel.replace(os.sep, "/"), []).append(f)
for k, v in groups.items(): print(k + "\t" + json.dumps(v))
PY
)
  rstep="$JOB/$base.aiflow.step"
  res=$(run_cli 600 "board STEP export for adom-aiflow" "" pcb export step "${pass[@]}" --output "$rstep" "$JOB/$base.kicad_pcb") || true
  # the native output and exit status, unchanged (Windows shell_execute and Linux run_script both answer stdout/stderr/exitCode)
  echo "$res" | python3 -c 'import sys,json
try: d=json.load(sys.stdin)
except Exception: sys.exit(0)
sys.stdout.write(d.get("stdout","") or "");sys.stderr.write(d.get("stderr","") or "")' || true
  rc=$(echo "$res" | python3 -c 'import sys,json
try: d=json.load(sys.stdin)
except Exception: print(1); sys.exit(0)
c=d.get("exitCode", d.get("code")); print(1 if c is None else int(c))' 2>/dev/null || echo 1)
  if fetch_one "$rstep" "$out"; then cleanup_remote "$JOB"; exit "$rc"; fi
  cleanup_remote "$JOB"
  echo "kicad-cli-remote: the STEP did not come back" >&2
  exit $(( rc == 0 ? 1 : rc ))
fi

if [[ "${1:-}" == "pcb" && "${2:-}" == "export" && "${3:-}" =~ ^(gerbers|drill|pos)$ ]]; then
  kind="$3"; shift 3
  if [[ " $* " == *" --help "* || " $* " == *" -h "* ]]; then
    echo "Usage: kicad-cli-remote pcb export $kind [flags] --output OUT INPUT_FILE (flags are passed to the desktop's native KiCad)"
    exit 0
  fi
  out=""; board=""; pass=()
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --output|-o) out="$2"; shift 2;;
      --layers|-l|--format|--units|--side|--excellon-units|-u|--excellon-oval-format|--excellon-zeros-format|--drill-origin|--map-format|--gerber-precision|--precision|--common-layers|--cl) pass+=("$1" "$2"); shift 2;;
      --*) pass+=("$1"); shift;;
      *) board="$1"; shift;;
    esac
  done
  [[ -f "$board" && -n "$out" ]] || { echo "kicad-cli-remote: need --output and an existing board" >&2; exit 2; }
  base="$(basename "$board" .kicad_pcb)"
  new_job export
  send_board "$board" "$JOB"
  rdir="$JOB/out"
  rout="$rdir/"; [[ "$kind" == "pos" ]] && rout="$rdir/$(basename "$out")"
  res=$(run_cli 600 "manufacturing export for adom-aiflow" "$rdir" pcb export "$kind" "${pass[@]}" --output "$rout" "$JOB/$base.kicad_pcb") || true
  # the local side, absolute (#61, #72): pull_file's saveTo and the checks below must name the same folder
  if [[ "$kind" == "pos" ]]; then
    names=("$(basename "$out")"); local_dir="$(dirname "$(absp "$out")")"
  else
    local_dir="$(absp "${out%/}")"
    mapfile -t names < <(list_remote "$rdir")
  fi
  mkdir -p "$local_dir"
  code=0
  if [[ ${#names[@]} -eq 0 ]]; then code=1; else
    paths=(); for n in "${names[@]}"; do paths+=("$rdir/$n"); done
    pull_to "$local_dir" "${paths[@]}"
    for n in "${names[@]}"; do [[ -f "$local_dir/$n" ]] || { code=1; echo "kicad-cli-remote: $n did not come back to $local_dir" >&2; }; done
  fi
  cleanup_remote "$JOB"
  if [[ $code -ne 0 ]]; then
    echo "kicad-cli-remote: the $kind export did not come back" >&2
    echo "$res" | tail_of
  fi
  exit $code
fi

[[ "${1:-}" == "pcb" && "${2:-}" == "drc" ]] || { echo "kicad-cli-remote: only 'pcb drc', 'sch erc', 'pcb export gerbers|drill|pos|step', 'sch export netlist', 'version' and '--version' are supported" >&2; exit 2; }
shift 2
if [[ " $* " == *" --help "* || " $* " == *" -h "* ]]; then
  echo "Usage: kicad-cli-remote pcb drc [--format json] [--output FILE] [--refill-zones] [--schematic-parity] [--severity-all] INPUT_FILE"
  echo "  --refill-zones      Refill zones before running DRC (passed to the desktop's native KiCad)"
  echo "  --schematic-parity  Test the board against its schematic (<board>.kicad_sch, else KICAD_REMOTE_SCHEMATIC; sent with its sub-sheets)"
  exit 0
fi
fmt=json; out=""; board=""; extra=()
while [[ $# -gt 0 ]]; do
  case "$1" in
    --format) fmt="$2"; shift 2;;
    --output|-o) out="$2"; shift 2;;
    --refill-zones|--severity-all|--severity-error|--severity-warning|--severity-exclusions|--all-track-errors|--schematic-parity|--exit-code-violations) extra+=("$1"); shift;;
    --units) extra+=("$1" "$2"); shift 2;;
    --*) shift;;
    *) board="$1"; shift;;
  esac
done
[[ -f "$board" && -n "$out" ]] || { echo "kicad-cli-remote: need --output and an existing board" >&2; exit 2; }
base="$(basename "$board" .kicad_pcb)"
# --schematic-parity: the schematic beside the board (KiCad's own choice), else KICAD_REMOTE_SCHEMATIC
psch=""
if [[ " ${extra[*]:-} " == *" --schematic-parity "* ]]; then
  if [[ -f "${board%.kicad_pcb}.kicad_sch" ]]; then psch="${board%.kicad_pcb}.kicad_sch"
  elif [[ -n "${KICAD_REMOTE_SCHEMATIC:-}" && -f "$KICAD_REMOTE_SCHEMATIC" ]]; then psch="$KICAD_REMOTE_SCHEMATIC"
  else echo "kicad-cli-remote: --schematic-parity but no $base.kicad_sch beside the board and no KICAD_REMOTE_SCHEMATIC: KiCad will find no schematic to compare" >&2
  fi
fi
new_job drc
send_board "$board" "$JOB" "$psch"
rrep="$JOB/$base.drc.$fmt"
res=$(run_cli 240 "offline KiCad DRC for adom-aiflow" "" pcb drc "${extra[@]}" --format "$fmt" --output "$rrep" "$JOB/$base.kicad_pcb") || true
if fetch_one "$rrep" "$out"; then cleanup_remote "$JOB"; exit 0; fi
cleanup_remote "$JOB"
echo "kicad-cli-remote: no report came back" >&2
echo "$res" | tail_of
exit 1