Add standalone jerbuild binary

ober

63f70de4d041a69b09434477ef76c09d12549bac

diff --git a/Makefile b/Makefile
index b168a84..6921db8 100644
--- a/Makefile
+++ b/Makefile
@@ -147,6 +147,38 @@ BINARY_OUTPUT ?= jerboa-bin
 binary: chez build
 	SCHEME=$(SCHEME) JERBOA_CHEZ_PREFIX=$(CHEZ_PREFIX) support/build-binary.sh $(BINARY_ENTRY) $(BINARY_OUTPUT)
 
+# ── jerbuild standalone binary ───────────────────────────────────────────────
+# Builds a self-contained ./jerbuild that bundles Chez + the Jerboa stdlib
+# (lib/jerboa + lib/std). On any box with cargo/rustc/cc, this single binary
+# is enough to transpile any Jerboa project's .ss sources to .sls.
+#
+#   ./jerbuild --jerboa-home     # extract bundled lib/, print path
+#   ./jerbuild <src> <lib>       # transpile
+.PHONY: jerbuild jerbuild-smoke
+jerbuild: chez build
+	SCHEME=$(SCHEME) JERBOA_CHEZ_PREFIX=$(CHEZ_PREFIX) support/build-jerbuild.sh
+
+# End-to-end smoke test: with no JERBOA_HOME and only ./jerbuild, transpile a
+# small (export greet) module, then exec a script that imports + calls it.
+# Catches regressions in transpile + bundled stdlib + exec subcommand.
+jerbuild-smoke: jerbuild
+	@SMOKE=$$(mktemp -d /tmp/jerbuild-smoke.XXXX); \
+	mkdir -p $$SMOKE/in/demo; \
+	printf '(export greet)\n(def (greet n) (format "hello, ~a!" n))\n' \
+	    > $$SMOKE/in/demo/hello.ss; \
+	printf '(import (demo hello)) (import (chezscheme))\n(display (greet "jerbuild")) (newline)\n' \
+	    > $$SMOKE/main.ss; \
+	cd $$SMOKE && env -u JERBOA_HOME $(CURDIR)/jerbuild in out >/dev/null; \
+	JH=$$(env -u JERBOA_HOME $(CURDIR)/jerbuild --jerboa-home); \
+	out=$$(cd $$SMOKE && env -u JERBOA_HOME $(CURDIR)/jerbuild exec --libdirs out:$$JH/lib main.ss 2>&1); \
+	rm -rf $$SMOKE; \
+	if [ "$$out" = "hello, jerbuild!" ]; then \
+	    echo "jerbuild-smoke: PASS — $$out"; \
+	else \
+	    echo "jerbuild-smoke: FAIL — expected 'hello, jerbuild!', got: $$out" >&2; \
+	    exit 1; \
+	fi
+
 # ── Cross-compilation ────────────────────────────────────────────────────────
 # Override these to cross-compile:
 #   CHEZ_TARGET_MACHINE — Chez machine type (e.g. ta6osx, ta6le, tarm64le, ta6fb)
@@ -323,6 +355,27 @@ typed-wrappers:
 typed-build: typed-rust typed-wrappers
 	@cargo build --manifest-path $(TYPED_RUST_DIR)/Cargo.toml
 
+# Project-attach: emit library-form wrappers (one .sls per typed module,
+# importable as `(library-name ...)`) plus the Rust crate. Used by sibling
+# projects (e.g. jerboa-code) to compile typed modules into their own libdir
+# and link the resulting archive into their own binary.
+#
+# Required:
+#   TYPED_PROJECT_SOURCES     — typed .ss source files
+#   TYPED_PROJECT_WRAPPER_DIR — libdir root where .sls wrappers are written
+#                               (e.g. .../src for (jcode typed pricing) →
+#                               .../src/jcode/typed/pricing.sls)
+#   TYPED_PROJECT_RUST_DIR    — Rust crate output directory
+typed-project-build:
+	@test -n "$(TYPED_PROJECT_SOURCES)" || { echo "ERROR: set TYPED_PROJECT_SOURCES" >&2; exit 1; }
+	@test -n "$(TYPED_PROJECT_WRAPPER_DIR)" || { echo "ERROR: set TYPED_PROJECT_WRAPPER_DIR" >&2; exit 1; }
+	@test -n "$(TYPED_PROJECT_RUST_DIR)" || { echo "ERROR: set TYPED_PROJECT_RUST_DIR" >&2; exit 1; }
+	@$(SCHEME) --libdirs $(LIBDIRS) --script support/typed-rust.ss \
+	  $(TYPED_PROJECT_RUST_DIR) $(TYPED_PROJECT_SOURCES)
+	@$(SCHEME) --libdirs $(LIBDIRS) --script support/typed-wrappers.ss \
+	  --library $(TYPED_PROJECT_WRAPPER_DIR) $(TYPED_PROJECT_SOURCES)
+	@cargo build --release --manifest-path $(TYPED_PROJECT_RUST_DIR)/Cargo.toml
+
 typed-wrapper-smoke:
 	@$(MAKE) --no-print-directory typed-build TYPED_RUST_SOURCES=tests/fixtures/typed/rust-basic.ss TYPED_WRAPPER_DIR=build/typed/jerboa-smoke
 	@lib="$$(find $(TYPED_RUST_DIR)/target/debug -maxdepth 1 \( -name 'libjerboa_typed_generated.dylib' -o -name 'libjerboa_typed_generated.so' \) -print | head -n 1)"; \
diff --git a/jerbuild.ss b/jerbuild.ss
index 76b78d4..25b7d34 100644
--- a/jerbuild.ss
+++ b/jerbuild.ss
@@ -176,6 +176,30 @@
 ;;;; Reading and classifying source forms
 ;;;; ============================================================
 
+(define gerbil-char-name-table
+  ;; Gerbil/Jerboa character names → Chez character names.
+  ;; Chez recognises #\escape via R6RS extension; older Chez and the
+  ;; sequence #\xNN are universal. The most common Gerbil-only name in
+  ;; practice is #\escape; we also normalise a few aliases.
+  '(("escape" . "esc")))
+
+(define (gerbil-char-name->chez name)
+  (let ([hit (assoc name gerbil-char-name-table)])
+    (if hit (cdr hit) name)))
+
+(define (read-char-name str start len)
+  ;; Collect the alphabetic character-name starting at str[start].
+  ;; Returns (values name end-index) where str[end-index] is the first
+  ;; char that is NOT part of the name (whitespace or a delimiter).
+  (let loop ([j start] [acc '()])
+    (if (>= j len)
+      (values (list->string (reverse acc)) j)
+      (let ([c (string-ref str j)])
+        (if (or (char-whitespace? c)
+                (memv c '(#\( #\) #\[ #\] #\; #\")))
+          (values (list->string (reverse acc)) j)
+          (loop (+ j 1) (cons c acc)))))))
+
 (define (preprocess-brackets str)
   ;; Convert Jerboa/Chez bracket syntax [x y z] → (x y z)
   ;; in source text, while correctly skipping strings, comments,
@@ -209,33 +233,26 @@
                 (loop (+ i 1) #f #f)]
                [else
                 (loop (+ i 1) in-string #f)])]
-            ;; Character literal: #\x — copy # \ and the character name verbatim
-            ;; Must not convert #\[ or #\] — those are char literals, not list brackets
+            ;; Character literal: #\x — copy # \ and the character name verbatim.
+            ;; Must not convert #\[ or #\] — those are char literals, not brackets.
+            ;; Alphabetic names get translated through gerbil-char-name->chez so
+            ;; Gerbil-isms like #\escape become Chez-readable #\esc.
             [(and (char=? ch #\#)
                   (< (+ i 1) len)
                   (char=? (string-ref str (+ i 1)) #\\))
              (write-char ch out)
              (write-char (string-ref str (+ i 1)) out)
-             (if (>= (+ i 2) len)
-               (loop (+ i 2) #f #f)
-               (let ([nc (string-ref str (+ i 2))])
-                 ;; If single non-alphabetic char (like #\[ #\] #\( #\space etc.):
-                 ;; write it verbatim and continue WITHOUT going through the bracket handler
-                 (if (not (char-alphabetic? nc))
-                   (begin
-                     (write-char nc out)
-                     (loop (+ i 3) #f #f))
-                   ;; Alphabetic: read the whole name (e.g. newline, space, nul)
-                   (let char-loop ([j (+ i 2)])
-                     (if (>= j len)
-                       (loop j #f #f)
-                       (let ([ac (string-ref str j)])
-                         (if (or (char-whitespace? ac)
-                                 (memv ac '(#\( #\) #\[ #\] #\; #\")))
-                           (loop j #f #f)
-                           (begin
-                             (write-char ac out)
-                             (char-loop (+ j 1))))))))))]
+             (cond
+               [(>= (+ i 2) len)
+                (loop (+ i 2) #f #f)]
+               [(not (char-alphabetic? (string-ref str (+ i 2))))
+                (write-char (string-ref str (+ i 2)) out)
+                (loop (+ i 3) #f #f)]
+               [else
+                (let-values ([(name end)
+                              (read-char-name str (+ i 2) len)])
+                  (display (gerbil-char-name->chez name) out)
+                  (loop end #f #f))])]
             ;; Block comment #| ... |#
             [(and (char=? ch #\#)
                   (< (+ i 1) len)
@@ -284,6 +301,40 @@
             [(char=? ch #\])
              (write-char #\) out)
              (loop (+ i 1) #f #f)]
+            ;; Hash-bang datum: #!void → (void), #!eof → (eof-object).
+            ;; (Skips #!chezscheme / #!r6rs / #!fold-case / #!no-fold-case,
+            ;; which Chez handles natively, and #!optional / #!key / #!rest /
+            ;; #!default, which Chez also reads natively.)
+            [(and (char=? ch #\#)
+                  (< (+ i 1) len)
+                  (char=? (string-ref str (+ i 1)) #\!))
+             ;; Collect the identifier after #!
+             (let bang-loop ([j (+ i 2)])
+               (if (and (< j len)
+                        (let ([c (string-ref str j)])
+                          (or (char-alphabetic? c)
+                              (char-numeric? c)
+                              (memv c '(#\- #\_)))))
+                 (bang-loop (+ j 1))
+                 (let ([name (substring str (+ i 2) j)])
+                   (cond
+                     [(string=? name "void")
+                      (display "(void)" out)
+                      (loop j #f #f)]
+                     [(string=? name "eof")
+                      (display "(eof-object)" out)
+                      (loop j #f #f)]
+                     [(string=? name "unbound")
+                      ;; Treat like void at read time — call sites that want a
+                      ;; real unbound check should use cond-expand etc.
+                      (display "(void)" out)
+                      (loop j #f #f)]
+                     [else
+                      ;; Pass through: #!chezscheme, #!r6rs, #!fold-case,
+                      ;; #!no-fold-case, #!optional, #!key, #!rest, #!default
+                      (write-char ch out)
+                      (write-char (string-ref str (+ i 1)) out)
+                      (loop (+ i 2) #f #f)]))))]
             ;; Everything else — copy verbatim
             [else
              (write-char ch out)
@@ -291,8 +342,8 @@
 
 (define (read-source-file path)
   ;; Read all top-level S-expressions from a .ss file.
-  ;; Preprocesses Jerboa/Chez bracket syntax [x y z] → (x y z).
-  ;; Returns a list of forms.
+  ;; Preprocesses Jerboa/Chez bracket syntax [x y z] → (x y z)
+  ;; and hash-bang datums (#!void, #!eof).
   (let* ([raw (call-with-input-file path
                 (lambda (port)
                   (let ([p (open-output-string)])
@@ -1288,8 +1339,79 @@
       (exit 1))))
 
 ;;;; ============================================================
+;;;; Subcommands: exec / compile / transpile (default)
+;;;; ============================================================
+
+(define (split-libdirs str)
+  ;; Split a libdir list on : or ; into individual paths.
+  (let ([sep (if (string=? (or (getenv "OS") "") "Windows_NT") #\; #\:)])
+    (string-split-char str sep)))
+
+(define (parse-libdirs-flag args)
+  ;; Scan for --libdirs <path> (or --libdirs=<path>) anywhere in args.
+  ;; Returns (values libdir-list remaining-args).
+  (let loop ([args args] [libs '()] [acc '()])
+    (cond
+      [(null? args)
+       (values libs (reverse acc))]
+      [(string=? (car args) "--libdirs")
+       (when (null? (cdr args))
+         (error 'jerbuild "--libdirs requires a value"))
+       (loop (cddr args) (append libs (split-libdirs (cadr args))) acc)]
+      [(string-starts-with? (car args) "--libdirs=")
+       (let ([val (substring (car args) 10 (string-length (car args)))])
+         (loop (cdr args) (append libs (split-libdirs val)) acc))]
+      [else
+       (loop (cdr args) libs (cons (car args) acc))])))
+
+(define (apply-libdirs! libs)
+  (unless (null? libs)
+    (library-directories libs)))
+
+(define (run-exec args)
+  ;; jerbuild exec [--libdirs <p>] <script.ss> [script-args...]
+  (let-values ([(libs rest) (parse-libdirs-flag args)])
+    (when (null? rest)
+      (error 'jerbuild "exec: missing script path"))
+    (apply-libdirs! libs)
+    (command-line-arguments (cdr rest))
+    (load (car rest))))
+
+(define (run-compile args)
+  ;; jerbuild compile [--libdirs <p>] [--wpo] <file.ss>...
+  (let-values ([(libs rest) (parse-libdirs-flag args)])
+    (apply-libdirs! libs)
+    (compile-imported-libraries #t)
+    (let loop ([rest rest] [wpo? #f] [files '()])
+      (cond
+        [(null? rest)
+         (when wpo? (generate-wpo-files #t))
+         (when (null? files)
+           (error 'jerbuild "compile: no input files"))
+         (for-each
+           (lambda (f)
+             (printf "compiling ~a\n" f)
+             (load f))
+           (reverse files))]
+        [(string=? (car rest) "--wpo")
+         (loop (cdr rest) #t files)]
+        [else
+         (loop (cdr rest) wpo? (cons (car rest) files))]))))
+
+;;;; ============================================================
 ;;;; Entry point
 ;;;; ============================================================
 
-(let-values ([(src-dir lib-dir) (parse-args (command-line-arguments))])
-  (jerbuild src-dir lib-dir))
+(let ([args (command-line-arguments)])
+  (cond
+    [(and (pair? args) (string=? (car args) "exec"))
+     (run-exec (cdr args))]
+    [(and (pair? args) (string=? (car args) "compile"))
+     (run-compile (cdr args))]
+    [(and (pair? args) (string=? (car args) "transpile"))
+     (let-values ([(src-dir lib-dir) (parse-args (cdr args))])
+       (jerbuild src-dir lib-dir))]
+    [else
+     ;; Default: transpile (backward compat with existing Makefiles).
+     (let-values ([(src-dir lib-dir) (parse-args args)])
+       (jerbuild src-dir lib-dir))]))
diff --git a/support/build-jerbuild.sh b/support/build-jerbuild.sh
new file mode 100755
index 0000000..b587ce3
--- /dev/null
+++ b/support/build-jerbuild.sh
@@ -0,0 +1,302 @@
+#!/bin/sh
+# build-jerbuild.sh — Build the standalone `jerbuild` binary.
+#
+# jerbuild is a self-contained .ss → .sls transpiler. It embeds the Jerboa
+# stdlib (lib/jerboa + lib/std) as a tarball, so it can be used to build any
+# Jerboa project on a box with cargo/rustc/cc — without a Jerboa checkout
+# or a Chez install.
+#
+# Usage:
+#   support/build-jerbuild.sh
+#
+# Output:
+#   ./jerbuild               — the binary
+#
+# Runtime usage of the produced binary:
+#   jerbuild <src-dir> <lib-dir>     — transpile .ss → .sls
+#   jerbuild --jerboa-home           — extract bundled stdlib, print its path
+#   jerbuild --version               — print version + bundled-lib hash
+
+set -eu
+
+JERBOA_HOME="${JERBOA_HOME:-$(cd "$(dirname "$0")/.." && pwd)}"
+SCHEME="${SCHEME:-$JERBOA_HOME/.chez/bin/scheme}"
+JERBOA_CHEZ_PREFIX="${JERBOA_CHEZ_PREFIX:-$JERBOA_HOME/.chez}"
+CC="${CC:-cc}"
+OUTPUT="${OUTPUT:-jerbuild}"
+
+cd "$JERBOA_HOME"
+
+[ -x "$SCHEME" ] || { echo "ERROR: scheme not found at $SCHEME" >&2; exit 1; }
+[ -f "jerbuild.ss" ] || { echo "ERROR: jerbuild.ss not found in $JERBOA_HOME" >&2; exit 1; }
+
+HOST_OS=$(uname -s)
+case "$HOST_OS" in
+    Darwin)  OS_LIBS="-lm -lpthread -lncurses -liconv" ;;
+    Linux)   OS_LIBS="-lm -ldl -lpthread -lncurses" ;;
+    FreeBSD) OS_LIBS="-lm -lpthread -lncurses -L/usr/local/lib -liconv" ;;
+    *)       OS_LIBS="-lm -lpthread -lncurses" ;;
+esac
+
+MACHINE_TYPE=$("$SCHEME" -q <<'EOF'
+(display (machine-type)) (exit)
+EOF
+)
+
+CSV_DIR=""
+for prefix in "$JERBOA_CHEZ_PREFIX/lib" /usr/local/lib /usr/lib /usr/lib64 /opt/homebrew/lib /opt/local/lib; do
+    for d in "$prefix"/csv*/"$MACHINE_TYPE"; do
+        if [ -f "$d/libkernel.a" ] && [ -f "$d/scheme.h" ] && [ -f "$d/petite.boot" ]; then
+            CSV_DIR="$d"
+            break 2
+        fi
+    done
+done
+[ -n "$CSV_DIR" ] || { echo "ERROR: no Chez install found for $MACHINE_TYPE" >&2; exit 1; }
+
+echo "=== jerbuild binary build ==="
+echo "    Host:  $HOST_OS, $MACHINE_TYPE"
+echo "    CC:    $CC"
+echo "    Chez:  $CSV_DIR"
+echo "    Out:   ./$OUTPUT"
+echo ""
+
+WPO_SO="${OUTPUT}.wp.so"
+OBJ_DIR=$(mktemp -d "/tmp/jerbuild-obj.XXXXXX")
+BUNDLE_TAR=$(mktemp "/tmp/jerbuild-bundle.XXXXXX.tar")
+trap 'rm -rf "$OBJ_DIR" "$WPO_SO" "$BUNDLE_TAR" petite_boot.h scheme_boot.h program_boot.h bundle_tar.h bundle_meta.h "${OUTPUT}-main.c"' EXIT
+
+echo "==> [1/5] WPO compile jerbuild.ss"
+"$SCHEME" --libdirs "$JERBOA_HOME/lib" \
+    --script "$JERBOA_HOME/support/build-boot.ss" \
+    jerbuild.ss "$WPO_SO" "$OBJ_DIR"
+echo ""
+
+echo "==> [2/5] Tarball lib/jerboa + lib/std (source files only)"
+# Only .ss and .sls — strip cached .so/.wpo build artifacts.
+# Use find + cpio piping so we don't depend on GNU tar features.
+(cd "$JERBOA_HOME" && find lib/jerboa lib/std \
+    \( -name '*.ss' -o -name '*.sls' \) -type f \
+    | sort \
+    | tar -cf "$BUNDLE_TAR" -T -)
+BUNDLE_BYTES=$(wc -c < "$BUNDLE_TAR" | tr -d ' ')
+BUNDLE_SHA=$(shasum -a 256 "$BUNDLE_TAR" 2>/dev/null | awk '{print $1}' \
+    || sha256sum "$BUNDLE_TAR" | awk '{print $1}')
+echo "    bundle: $BUNDLE_BYTES bytes, sha256=${BUNDLE_SHA%????????????????????????????????????????????????}…"
+echo ""
+
+echo "==> [3/5] Embed boot files + program + bundle as C arrays"
+embed() {
+    in="$1"; stem="$2"
+    var="${stem}_data"; sz="${stem}_size"; out="$stem.h"
+    printf 'static const unsigned char %s[] = {\n' "$var" > "$out"
+    od -An -tx1 -v "$in" \
+        | sed -e '/^[[:space:]]*$/d' \
+              -e 's/^ *//;s/ *$//;s/  */ /g;s/ /,0x/g;s/^/0x/;s/$/,/' >> "$out"
+    printf '};\nstatic const unsigned int %s = sizeof(%s);\n' "$sz" "$var" >> "$out"
+}
+embed "$CSV_DIR/petite.boot" petite_boot
+embed "$CSV_DIR/scheme.boot" scheme_boot
+embed "$WPO_SO"              program_boot
+embed "$BUNDLE_TAR"          bundle_tar
+
+cat > bundle_meta.h <<EOF
+static const char bundle_sha256[] = "$BUNDLE_SHA";
+EOF
+echo ""
+
+echo "==> [4/5] Generate ${OUTPUT}-main.c"
+cat > "${OUTPUT}-main.c" <<'CMAIN'
+/* jerbuild — standalone Jerboa .ss → .sls transpiler.
+ * Generated by support/build-jerbuild.sh. */
+#include "scheme.h"
+#include <errno.h>
+#include <fcntl.h>
+#include <pwd.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+#include "petite_boot.h"
+#include "scheme_boot.h"
+#include "program_boot.h"
+#include "bundle_tar.h"
+#include "bundle_meta.h"
+
+/* Cache layout:
+ *   $XDG_CACHE_HOME/jerbuild/<sha256>/         (or ~/.cache/jerbuild/<sha256>/)
+ *     bundle.tar         — bytes baked into binary, written on first run
+ *     .complete          — marker file (touch after successful extract)
+ *     lib/jerboa/...     — extracted Jerboa stdlib
+ *     lib/std/...        — extracted std library
+ *
+ * The sha256 in the path means a new binary version transparently lives in
+ * its own directory; old extractions stay valid for older binaries.
+ */
+
+static int mkdir_p(const char *path) {
+    char tmp[1024];
+    size_t n = strlen(path);
+    if (n + 1 > sizeof(tmp)) { errno = ENAMETOOLONG; return -1; }
+    memcpy(tmp, path, n + 1);
+    for (size_t i = 1; i < n; i++) {
+        if (tmp[i] == '/') {
+            tmp[i] = '\0';
+            if (mkdir(tmp, 0755) != 0 && errno != EEXIST) return -1;
+            tmp[i] = '/';
+        }
+    }
+    if (mkdir(tmp, 0755) != 0 && errno != EEXIST) return -1;
+    return 0;
+}
+
+static const char *cache_root(char *buf, size_t n) {
+    const char *xdg = getenv("XDG_CACHE_HOME");
+    if (xdg && *xdg) {
+        snprintf(buf, n, "%s/jerbuild", xdg);
+        return buf;
+    }
+    const char *home = getenv("HOME");
+    if (!home || !*home) {
+        struct passwd *pw = getpwuid(getuid());
+        home = pw ? pw->pw_dir : "/tmp";
+    }
+    snprintf(buf, n, "%s/.cache/jerbuild", home);
+    return buf;
+}
+
+static const char *ensure_extracted(void) {
+    static char root[1024];
+    static char target[1024];
+    static char marker[1100];
+    static char bundle[1100];
+    static int  ready = 0;
+    if (ready) return target;
+
+    cache_root(root, sizeof(root));
+    snprintf(target, sizeof(target), "%s/%s", root, bundle_sha256);
+    snprintf(marker, sizeof(marker), "%s/.complete", target);
+
+    struct stat st;
+    if (stat(marker, &st) == 0) { ready = 1; return target; }
+
+    if (mkdir_p(target) != 0) {
+        fprintf(stderr, "jerbuild: mkdir -p %s: %s\n", target, strerror(errno));
+        exit(1);
+    }
+
+    snprintf(bundle, sizeof(bundle), "%s/bundle.tar", target);
+    int fd = open(bundle, O_WRONLY | O_CREAT | O_TRUNC, 0644);
+    if (fd < 0) {
+        fprintf(stderr, "jerbuild: open %s: %s\n", bundle, strerror(errno));
+        exit(1);
+    }
+    ssize_t w = write(fd, bundle_tar_data, bundle_tar_size);
+    close(fd);
+    if (w != (ssize_t)bundle_tar_size) {
+        fprintf(stderr, "jerbuild: short write to %s\n", bundle);
+        exit(1);
+    }
+
+    /* Extract via system tar. cd-into-target before extract so paths are
+     * relative to $target/, not $CWD. */
+    char cmd[2400];
+    snprintf(cmd, sizeof(cmd), "cd '%s' && tar -xf '%s'", target, bundle);
+    int rc = system(cmd);
+    if (rc != 0) {
+        fprintf(stderr, "jerbuild: tar -xf %s failed (rc=%d)\n", bundle, rc);
+        exit(1);
+    }
+    unlink(bundle);
+
+    fd = open(marker, O_WRONLY | O_CREAT | O_TRUNC, 0644);
+    if (fd < 0) {
+        fprintf(stderr, "jerbuild: cannot touch %s: %s\n", marker, strerror(errno));
+        exit(1);
+    }
+    close(fd);
+
+    ready = 1;
+    return target;
+}
+
+static const char *write_program_tmpfile(void) {
+    static char path[] = "/tmp/jerbuild-prog-XXXXXX";
+    int fd = mkstemp(path);
+    if (fd < 0) { perror("mkstemp"); exit(1); }
+    ssize_t n = write(fd, program_boot_data, program_boot_size);
+    if (n != (ssize_t)program_boot_size) {
+        perror("write"); close(fd); unlink(path); exit(1);
+    }
+    close(fd);
+    return path;
+}
+
+int main(int argc, const char *argv[]) {
+    /* Fast paths that avoid booting Chez. */
+    if (argc == 2 && strcmp(argv[1], "--jerboa-home") == 0) {
+        printf("%s\n", ensure_extracted());
+        return 0;
+    }
+    if (argc == 2 && strcmp(argv[1], "--bundle-sha256") == 0) {
+        printf("%s\n", bundle_sha256);
+        return 0;
+    }
+    if (argc == 2 && strcmp(argv[1], "--version") == 0) {
+        printf("jerbuild (bundled-lib sha256=%s)\n", bundle_sha256);
+        return 0;
+    }
+    if (argc == 2 && (strcmp(argv[1], "-h") == 0 ||
+                      strcmp(argv[1], "--help") == 0)) {
+        fputs(
+            "Usage:\n"
+            "  jerbuild <src> <lib>                       # transpile .ss -> .sls\n"
+            "  jerbuild transpile <src> <lib> [--force]\n"
+            "  jerbuild compile --libdirs <p> [--wpo] FILE...\n"
+            "                                             # compile-imported-libs\n"
+            "  jerbuild exec --libdirs <p> FILE [args...] # load+run a Scheme script\n"
+            "  jerbuild --jerboa-home                     # extract+print stdlib path\n"
+            "  jerbuild --version\n",
+            stdout);
+        return 0;
+    }
+
+    Sscheme_init(NULL);
+    Sregister_boot_file_bytes("petite",
+        (void *)petite_boot_data, petite_boot_size);
+    Sregister_boot_file_bytes("scheme",
+        (void *)scheme_boot_data, scheme_boot_size);
+    Sbuild_heap(NULL, NULL);
+
+    const char *prog_path = write_program_tmpfile();
+    int status = Sscheme_program(prog_path, argc, argv);
+    unlink(prog_path);
+
+    Sscheme_deinit();
+    return status;
+}
+CMAIN
+echo ""
+
+echo "==> [5/5] Compile + link -> $OUTPUT"
+EXTRA_ARCHIVES=""
+for a in liblz4.a libz.a; do
+    [ -f "$CSV_DIR/$a" ] && EXTRA_ARCHIVES="$EXTRA_ARCHIVES $CSV_DIR/$a"
+done
+
+# shellcheck disable=SC2086
+$CC -I. -I"$CSV_DIR" -O2 \
+    -o "$OUTPUT" \
+    "${OUTPUT}-main.c" \
+    "$CSV_DIR/libkernel.a" \
+    $EXTRA_ARCHIVES \
+    $OS_LIBS
+
+echo ""
+echo "=== Build complete ==="
+ls -lh "$OUTPUT"
+file "$OUTPUT" 2>/dev/null || true