Add Rust/C extras + .jerbuild config to `jerbuild binary`

ober

2452c2feeb1be87827f3ea37f2fe170e51b952e0

diff --git a/jerbuild.ss b/jerbuild.ss
index 5722b1b..3b4fcb4 100644
--- a/jerbuild.ss
+++ b/jerbuild.ss
@@ -1503,131 +1503,448 @@ int main(int argc, const char *argv[]) {
 }
 ")
 
+;;;; ============================================================
+;;;; Helpers for binary subcommand extras (Rust crates, C shims)
+;;;; ============================================================
+
+(define (parse-multi-flag args flag-name)
+  ;; Collect every occurrence of --<flag-name> <value> (or =<value>).
+  ;; Returns (values values-list remaining-args), preserving order.
+  (let ([eq-prefix (string-append flag-name "=")])
+    (let loop ([args args] [vals '()] [acc '()])
+      (cond
+        [(null? args) (values (reverse vals) (reverse acc))]
+        [(string=? (car args) flag-name)
+         (when (null? (cdr args))
+           (error 'jerbuild (format "~a requires a value" flag-name)))
+         (loop (cddr args) (cons (cadr args) vals) acc)]
+        [(string-starts-with? (car args) eq-prefix)
+         (let ([val (substring (car args)
+                               (string-length eq-prefix)
+                               (string-length (car args)))])
+           (loop (cdr args) (cons val vals) acc))]
+        [else
+         (loop (cdr args) vals (cons (car args) acc))]))))
+
+(define (parse-rust-crate-spec spec)
+  ;; "path/Cargo.toml"               -> (values "path/Cargo.toml" #f)
+  ;; "path/Cargo.toml:crypto,sqlite" -> (values "path/Cargo.toml" "crypto,sqlite")
+  ;; Splits on the FIRST colon. Cargo.toml paths with colons are not supported.
+  (let ([len (string-length spec)])
+    (let loop ([i 0])
+      (cond
+        [(>= i len) (values spec #f)]
+        [(char=? (string-ref spec i) #\:)
+         (values (substring spec 0 i)
+                 (substring spec (+ i 1) len))]
+        [else (loop (+ i 1))]))))
+
+(define (path-dirname p)
+  (let loop ([i (- (string-length p) 1)])
+    (cond
+      [(< i 0) "."]
+      [(char=? (string-ref p i) #\/)
+       (if (zero? i) "/" (substring p 0 i))]
+      [else (loop (- i 1))])))
+
+(define (path-basename p)
+  (let loop ([i (- (string-length p) 1)])
+    (cond
+      [(< i 0) p]
+      [(char=? (string-ref p i) #\/)
+       (substring p (+ i 1) (string-length p))]
+      [else (loop (- i 1))])))
+
+(define (list-archives-in dir)
+  ;; Top-level .a files under dir as absolute paths. Empty list if dir missing.
+  (if (file-exists? dir)
+    (sort string<?
+          (map (lambda (n) (format "~a/~a" dir n))
+               (filter (lambda (n) (string-ends-with? n ".a"))
+                       (directory-list dir))))
+    '()))
+
+(define (build-rust-crate spec)
+  ;; spec: "path/Cargo.toml[:features]"
+  ;; Drives `cargo build --release [--features F]`; returns list of .a paths.
+  (let-values ([(cargo-toml features) (parse-rust-crate-spec spec)])
+    (unless (file-exists? cargo-toml)
+      (error 'jerbuild (format "rust-crate: Cargo.toml not found: ~a" cargo-toml)))
+    (let* ([manifest-dir (path-dirname cargo-toml)]
+           [features-arg (if features
+                           (format " --features ~a" (shell-quote features))
+                           "")]
+           [cmd (format "cargo build --release --manifest-path ~a~a"
+                        (shell-quote cargo-toml) features-arg)])
+      (printf "    ~a\n" cmd)
+      (let ([rc (system cmd)])
+        (unless (zero? rc)
+          (error 'jerbuild
+                 (format "rust-crate: cargo build failed (rc=~a) for ~a"
+                         rc cargo-toml))))
+      (let* ([release-dir (format "~a/target/release" manifest-dir)]
+             [archives (list-archives-in release-dir)])
+        (when (null? archives)
+          (error 'jerbuild
+                 (format "rust-crate: no .a files found in ~a after cargo build"
+                         release-dir)))
+        (for-each (lambda (a) (printf "      -> ~a\n" a)) archives)
+        archives))))
+
+(define (compile-c-source src obj-dir cc csv-dir index)
+  ;; Compile src to obj-dir/extra-<index>-<basename-without-.c>.o
+  (let* ([base (path-basename src)]
+         [stem (if (string-ends-with? base ".c")
+                 (substring base 0 (- (string-length base) 2))
+                 base)]
+         [out  (format "~a/extra-~a-~a.o" obj-dir index stem)]
+         [cmd  (format "~a -I~a -O2 -c ~a -o ~a"
+                       cc
+                       (shell-quote csv-dir)
+                       (shell-quote src)
+                       (shell-quote out))])
+    (printf "    ~a\n" cmd)
+    (let ([rc (system cmd)])
+      (unless (zero? rc)
+        (error 'jerbuild
+               (format "extra-source: cc failed (rc=~a) for ~a" rc src))))
+    out))
+
+(define (join-quoted items)
+  (apply string-append
+         (map (lambda (x) (string-append " " (shell-quote x))) items)))
+
+(define (join-raw items)
+  (apply string-append
+         (map (lambda (x) (string-append " " x)) items)))
+
 (define (run-binary args)
-  ;; jerbuild binary [--libdirs <p>] [--cc <prog>] <entry.ss> <output>
+  ;; jerbuild binary [--libdirs P] [--cc CC]
+  ;;                 [--extra-archive A]                   (repeatable)
+  ;;                 [--extra-source S.c]                  (repeatable)
+  ;;                 [--extra-ldflag F]                    (repeatable)
+  ;;                 [--rust-crate Cargo.toml[:features]]  (repeatable)
+  ;;                 <entry.ss> <output>
   ;;
   ;; Builds a standalone executable that bundles Chez + the user's entry script
-  ;; (WPO compiled). The user gets a single binary with no external deps beyond
-  ;; system libc + libncurses.
-  (let-values ([(libs rest1) (parse-libdirs-flag args)])
-    (let-values ([(cc-arg rest) (parse-cc-flag rest1)])
-      (when (< (length rest) 2)
+  ;; (WPO compiled), plus any prebuilt archives, freshly-built Rust crates, and
+  ;; compiled C shims the project needs.
+  (let-values ([(libs args)            (parse-libdirs-flag args)])
+    (let-values ([(cc-arg args)         (parse-cc-flag args)])
+      (let-values ([(extra-archives args) (parse-multi-flag args "--extra-archive")])
+        (let-values ([(extra-sources args) (parse-multi-flag args "--extra-source")])
+          (let-values ([(extra-ldflags args) (parse-multi-flag args "--extra-ldflag")])
+            (let-values ([(rust-crates rest) (parse-multi-flag args "--rust-crate")])
+              (do-binary-build libs cc-arg extra-archives extra-sources
+                               extra-ldflags rust-crates rest))))))))
+
+(define (do-binary-build libs cc-arg extra-archives extra-sources
+                         extra-ldflags rust-crates rest)
+  (when (< (length rest) 2)
+    (error 'jerbuild
+           "binary: usage: jerbuild binary [--libdirs P] [--cc CC] [--extra-archive A] [--extra-source S.c] [--extra-ldflag F] [--rust-crate Cargo.toml[:features]] <entry.ss> <output>"))
+  (let* ([entry      (car rest)]
+         [output     (cadr rest)]
+         [cc         (or cc-arg (or (getenv "CC") "cc"))]
+         [bundle-dir (or (getenv "JERBUILD_BUNDLE_DIR")
+                         (error 'jerbuild
+                                "binary: JERBUILD_BUNDLE_DIR not set (jerbuild internal bug)"))]
+         [mt         (machine-type)]
+         [csv-dir    (format "~a/csv/~a" bundle-dir mt)]
+         [petite-boot (format "~a/petite.boot" csv-dir)]
+         [scheme-boot (format "~a/scheme.boot" csv-dir)]
+         [libkernel   (format "~a/libkernel.a" csv-dir)]
+         [scheme-h    (format "~a/scheme.h" csv-dir)])
+
+    (unless (file-exists? entry)
+      (error 'jerbuild (format "binary: entry not found: ~a" entry)))
+    (for-each
+      (lambda (a)
+        (unless (file-exists? a)
+          (error 'jerbuild (format "binary: --extra-archive not found: ~a" a))))
+      extra-archives)
+    (for-each
+      (lambda (s)
+        (unless (file-exists? s)
+          (error 'jerbuild (format "binary: --extra-source not found: ~a" s))))
+      extra-sources)
+    (unless (and (file-exists? libkernel) (file-exists? scheme-h)
+                 (file-exists? petite-boot) (file-exists? scheme-boot))
+      (error 'jerbuild
+             (format "binary: required Chez files missing under ~a (bundle was built without csv files for this machine-type)"
+                     csv-dir)))
+
+    (let* ([bundle-lib (format "~a/lib" bundle-dir)]
+           [user-libs (let loop ([xs libs] [seen '()] [acc '()])
+                        (cond
+                          [(null? xs) (reverse acc)]
+                          [(or (member (car xs) seen)
+                               (string=? (car xs) bundle-lib))
+                           (loop (cdr xs) seen acc)]
+                          [else
+                           (loop (cdr xs)
+                                 (cons (car xs) seen)
+                                 (cons (car xs) acc))]))]
+           [obj-dir       (format "/tmp/jerbuild-binary-~a" (get-process-id))]
+           [program-so    (format "~a/program.so" obj-dir)]
+           [program-wpo   (format "~a/program.wpo" obj-dir)]
+           [program-wp-so (format "~a/program.wp.so" obj-dir)]
+           [petite-hdr    (format "~a/petite_boot.h" obj-dir)]
+           [scheme-hdr    (format "~a/scheme_boot.h" obj-dir)]
+           [program-hdr   (format "~a/program_boot.h" obj-dir)]
+           [main-c        (format "~a/main.c" obj-dir)])
+
+      (printf "=== jerbuild binary build ===\n")
+      (printf "    Entry:  ~a\n" entry)
+      (printf "    Output: ~a\n" output)
+      (printf "    Host:   ~a\n" mt)
+      (printf "    CC:     ~a\n" cc)
+      (printf "    Chez:   ~a\n" csv-dir)
+      (unless (null? rust-crates)
+        (printf "    Rust crates:    ~a\n" (length rust-crates)))
+      (unless (null? extra-archives)
+        (printf "    Extra archives: ~a\n" (length extra-archives)))
+      (unless (null? extra-sources)
+        (printf "    Extra sources:  ~a\n" (length extra-sources)))
+      (newline)
+
+      (system (format "mkdir -p ~a" (shell-quote obj-dir)))
+
+      ;; Set library-directories: user libs redirect to obj-dir (so
+      ;; compile-imported-libraries doesn't pollute user source trees);
+      ;; bundle lib is plain (precompiled .so/.wpo live there already).
+      ;;
+      ;; jerbuild's own WPO image internalizes libraries reachable from
+      ;; (jerboa build) — (jerboa core), (std misc string), (std pregexp), etc.
+      ;; When user code imports any of these, Chez treats them as "already
+      ;; loaded" and skips compilation, so compile-whole-program needs their
+      ;; .wpo files on disk. build-jerbuild.sh stages them next to the .sls
+      ;; sources in the bundle.
+      (library-directories
+        (append (map (lambda (l) (cons l obj-dir)) user-libs)
+                (list bundle-lib)))
+
+      (compile-imported-libraries #t)
+      (generate-wpo-files #t)
+
+      (printf "==> [1/5] WPO compile ~a\n" entry)
+      (compile-program entry program-so)
+      (compile-whole-program program-wpo program-wp-so #t)
+
+      (let* ([rust-archives
+              (cond
+                [(null? rust-crates) '()]
+                [else
+                 (printf "==> [2/5] Build Rust crates (~a)\n" (length rust-crates))
+                 (apply append (map build-rust-crate rust-crates))])]
+             [user-objs
+              (cond
+                [(null? extra-sources) '()]
+                [else
+                 (printf "==> [2/5] Compile C sources (~a)\n" (length extra-sources))
+                 (let loop ([srcs extra-sources] [i 0] [acc '()])
+                   (cond
+                     [(null? srcs) (reverse acc)]
+                     [else
+                      (loop (cdr srcs)
+                            (+ i 1)
+                            (cons (compile-c-source
+                                    (car srcs) obj-dir cc csv-dir i)
+                                  acc))]))])])
+
+        (printf "==> [3/5] Embed boots + program as C arrays\n")
+        (embed-bytes-as-c-array petite-boot   petite-hdr  "petite_boot")
+        (embed-bytes-as-c-array scheme-boot   scheme-hdr  "scheme_boot")
+        (embed-bytes-as-c-array program-wp-so program-hdr "program_boot")
+
+        (printf "==> [4/5] Generate main.c\n")
+        (call-with-output-file main-c
+          (lambda (port) (display *binary-main-c-template* port))
+          'replace)
+
+        (printf "==> [5/5] Compile + link -> ~a\n" output)
+        (let* ([chez-archives
+                (apply string-append
+                  (map (lambda (a)
+                         (let ([p (format "~a/~a" csv-dir a)])
+                           (if (file-exists? p)
+                             (string-append " " (shell-quote p))
+                             "")))
+                       '("liblz4.a" "libz.a")))]
+               ;; Link order: main.c -> user .o -> Rust .a -> extra .a ->
+               ;; libkernel -> lz4/z -> extra ldflags -> OS libs.
+               ;; Anything that uses Scheme_* symbols must come before libkernel;
+               ;; anything libkernel needs (lz4/z, ncurses) must come after.
+               [cc-cmd (format "~a -I~a -I~a -O2 -o ~a ~a~a~a~a ~a~a~a ~a"
+                               cc
+                               (shell-quote obj-dir)
+                               (shell-quote csv-dir)
+                               (shell-quote output)
+                               (shell-quote main-c)
+                               (join-quoted user-objs)
+                               (join-quoted rust-archives)
+                               (join-quoted extra-archives)
+                               (shell-quote libkernel)
+                               chez-archives
+                               (join-raw extra-ldflags)
+                               (machine-type->os-libs mt))])
+          (printf "    ~a\n" cc-cmd)
+          (let ([rc (system cc-cmd)])
+            (unless (zero? rc)
+              (error 'jerbuild (format "binary: cc exited ~a" rc))))))
+
+      (system (format "rm -rf ~a" (shell-quote obj-dir)))
+      (printf "\n=== Build complete: ~a ===\n" output))))
+
+;;;; ============================================================
+;;;; .jerbuild config file (per-repo build settings, s-expression)
+;;;; ============================================================
+;;
+;; Recognized top-level forms:
+;;
+;;   (entry  "path/main.ss")             ; required
+;;   (output "binary-name")              ; required
+;;   (libdirs "." "src")                 ; optional, zero or more
+;;   (cc "cc")                           ; optional; CLI --cc overrides
+;;   (rust-crates
+;;     "path/Cargo.toml"
+;;     ("other/Cargo.toml" features: "crypto,sqlite"))
+;;   (extra-sources "shim.c" "other.c")  ; optional
+;;   (extra-archives "vendor/lib.a")     ; optional
+;;   (extra-ldflags "-framework" "Security")  ; optional, raw strings
+;;
+;; All paths are resolved relative to the .jerbuild file's directory.
+
+(define (read-all-sexps path)
+  (call-with-input-file path
+    (lambda (port)
+      (let loop ([acc '()])
+        (let ([s (read port)])
+          (if (eof-object? s)
+            (reverse acc)
+            (loop (cons s acc))))))))
+
+(define (find-config-from dir)
+  ;; Walk up from dir looking for .jerbuild. Returns absolute path or #f.
+  (let ([candidate (format "~a/.jerbuild" dir)])
+    (cond
+      [(file-exists? candidate) candidate]
+      [else
+       (let ([parent (path-dirname dir)])
+         (if (or (string=? parent dir) (string=? parent ""))
+           #f
+           (find-config-from parent)))])))
+
+(define (absolute-path? p)
+  (and (positive? (string-length p))
+       (char=? (string-ref p 0) #\/)))
+
+(define (resolve-config-path p base)
+  (if (absolute-path? p) p (format "~a/~a" base p)))
+
+(define (find-keyword key plist)
+  ;; Property-list lookup: (k1: v1 k2: v2) -> v1 for k1:.
+  (let loop ([xs plist])
+    (cond
+      [(or (null? xs) (null? (cdr xs))) #f]
+      [(eq? (car xs) key) (cadr xs)]
+      [else (loop (cddr xs))])))
+
+(define (config-rust-crate->spec form base)
+  (cond
+    [(string? form) (resolve-config-path form base)]
+    [(and (pair? form) (string? (car form)))
+     (let* ([cargo-path (resolve-config-path (car form) base)]
+            [features (find-keyword 'features: (cdr form))])
+       (if features
+         (format "~a:~a" cargo-path features)
+         cargo-path))]
+    [else
+     (error 'jerbuild (format "config: bad rust-crate entry: ~a" form))]))
+
+(define (load-jerbuild-config path)
+  ;; Returns (values entry output libdirs cc rust-crates
+  ;;                  extra-sources extra-archives extra-ldflags).
+  (let* ([config-dir (path-dirname path)]
+         [base (if (absolute-path? config-dir)
+                 config-dir
+                 (format "~a/~a" (current-directory) config-dir))]
+         [forms (read-all-sexps path)]
+         [entry #f] [output #f] [cc #f]
+         [libdirs '()] [rust-crates '()] [extra-sources '()]
+         [extra-archives '()] [extra-ldflags '()])
+    (for-each
+      (lambda (form)
+        (unless (and (pair? form) (symbol? (car form)))
+          (error 'jerbuild (format "config: expected (key ...) form, got ~a" form)))
+        (case (car form)
+          [(entry)
+           (unless (= (length form) 2)
+             (error 'jerbuild "config: (entry PATH) takes one value"))
+           (set! entry (resolve-config-path (cadr form) base))]
+          [(output)
+           (unless (= (length form) 2)
+             (error 'jerbuild "config: (output PATH) takes one value"))
+           (set! output (resolve-config-path (cadr form) base))]
+          [(cc)
+           (unless (= (length form) 2)
+             (error 'jerbuild "config: (cc CMD) takes one value"))
+           (set! cc (cadr form))]
+          [(libdirs)
+           (set! libdirs (map (lambda (p) (resolve-config-path p base)) (cdr form)))]
+          [(rust-crates)
+           (set! rust-crates
+                 (map (lambda (f) (config-rust-crate->spec f base)) (cdr form)))]
+          [(extra-sources)
+           (set! extra-sources
+                 (map (lambda (p) (resolve-config-path p base)) (cdr form)))]
+          [(extra-archives)
+           (set! extra-archives
+                 (map (lambda (p) (resolve-config-path p base)) (cdr form)))]
+          [(extra-ldflags)
+           (set! extra-ldflags (cdr form))]
+          [else
+           (error 'jerbuild
+                  (format "config: unknown key ~a in ~a (valid: entry output cc libdirs rust-crates extra-sources extra-archives extra-ldflags)"
+                          (car form) path))]))
+      forms)
+    (unless entry
+      (error 'jerbuild (format "config: missing required (entry PATH) in ~a" path)))
+    (unless output
+      (error 'jerbuild (format "config: missing required (output PATH) in ~a" path)))
+    (values entry output libdirs cc rust-crates
+            extra-sources extra-archives extra-ldflags)))
+
+(define (run-build args)
+  ;; jerbuild build [--cc CC] [--config PATH]
+  ;; Reads .jerbuild from cwd (or any parent) and runs do-binary-build.
+  ;; --cc overrides the config's (cc ...) setting.
+  (let-values ([(cc-arg rest1) (parse-cc-flag args)])
+    (let-values ([(config-paths rest) (parse-multi-flag rest1 "--config")])
+      (unless (null? rest)
         (error 'jerbuild
-               "binary: usage: jerbuild binary [--libdirs P] [--cc CC] <entry.ss> <output>"))
-      (let* ([entry      (car rest)]
-             [output     (cadr rest)]
-             [cc         (or cc-arg (or (getenv "CC") "cc"))]
-             [bundle-dir (or (getenv "JERBUILD_BUNDLE_DIR")
-                             (error 'jerbuild
-                                    "binary: JERBUILD_BUNDLE_DIR not set (jerbuild internal bug)"))]
-             [mt         (machine-type)]
-             [csv-dir    (format "~a/csv/~a" bundle-dir mt)]
-             [petite-boot (format "~a/petite.boot" csv-dir)]
-             [scheme-boot (format "~a/scheme.boot" csv-dir)]
-             [libkernel   (format "~a/libkernel.a" csv-dir)]
-             [scheme-h    (format "~a/scheme.h" csv-dir)])
-
-        (unless (file-exists? entry)
-          (error 'jerbuild (format "binary: entry not found: ~a" entry)))
-        (unless (and (file-exists? libkernel) (file-exists? scheme-h)
-                     (file-exists? petite-boot) (file-exists? scheme-boot))
-          (error 'jerbuild
-                 (format "binary: required Chez files missing under ~a (bundle was built without csv files for this machine-type)"
-                         csv-dir)))
-
-        ;; Libdirs: user-supplied first (so they can shadow), then bundled stdlib.
-        ;; Dedupe so passing --libdirs $bundle/lib doesn't double-add.
-        ;; User libs are redirected to obj-dir so compile-imported-libraries
-        ;; writes .so/.wpo there instead of next to user sources. Bundle lib
-        ;; keeps its plain mapping because precompiled .so/.wpo are bundled
-        ;; alongside the .sls sources (see build-jerbuild.sh step 2).
-        (let* ([bundle-lib (format "~a/lib" bundle-dir)]
-               [user-libs (let loop ([xs libs] [seen '()] [acc '()])
-                            (cond
-                              [(null? xs) (reverse acc)]
-                              [(or (member (car xs) seen)
-                                   (string=? (car xs) bundle-lib))
-                               (loop (cdr xs) seen acc)]
-                              [else
-                               (loop (cdr xs)
-                                     (cons (car xs) seen)
-                                     (cons (car xs) acc))]))])
-
-        (let* ([obj-dir       (format "/tmp/jerbuild-binary-~a" (get-process-id))]
-               [program-so    (format "~a/program.so" obj-dir)]
-               [program-wpo   (format "~a/program.wpo" obj-dir)]
-               [program-wp-so (format "~a/program.wp.so" obj-dir)]
-               [petite-hdr    (format "~a/petite_boot.h" obj-dir)]
-               [scheme-hdr    (format "~a/scheme_boot.h" obj-dir)]
-               [program-hdr   (format "~a/program_boot.h" obj-dir)]
-               [main-c        (format "~a/main.c" obj-dir)])
-
-          (printf "=== jerbuild binary build ===\n")
-          (printf "    Entry:  ~a\n" entry)
-          (printf "    Output: ~a\n" output)
-          (printf "    Host:   ~a\n" mt)
-          (printf "    CC:     ~a\n" cc)
-          (printf "    Chez:   ~a\n\n" csv-dir)
-
-          (system (format "mkdir -p ~a" (shell-quote obj-dir)))
-
-          ;; Set library-directories: user libs redirect to obj-dir (so
-          ;; compile-imported-libraries doesn't pollute user source trees);
-          ;; bundle lib is plain (precompiled .so/.wpo live there already).
-          ;;
-          ;; jerbuild's own WPO image internalizes libraries reachable from
-          ;; (jerboa build) — (jerboa core), (std misc string), (std pregexp), etc.
-          ;; When user code imports any of these, Chez treats them as "already
-          ;; loaded" and skips compilation, so compile-whole-program needs their
-          ;; .wpo files on disk. build-jerbuild.sh stages them next to the .sls
-          ;; sources in the bundle.
-          (library-directories
-            (append (map (lambda (l) (cons l obj-dir)) user-libs)
-                    (list bundle-lib)))
-
-          (compile-imported-libraries #t)
-          (generate-wpo-files #t)
-
-          (printf "==> [1/4] WPO compile ~a\n" entry)
-          (compile-program entry program-so)
-          (compile-whole-program program-wpo program-wp-so #t)
-
-          (printf "==> [2/4] Embed boots + program as C arrays\n")
-          (embed-bytes-as-c-array petite-boot   petite-hdr  "petite_boot")
-          (embed-bytes-as-c-array scheme-boot   scheme-hdr  "scheme_boot")
-          (embed-bytes-as-c-array program-wp-so program-hdr "program_boot")
-
-          (printf "==> [3/4] Generate main.c\n")
-          (call-with-output-file main-c
-            (lambda (port) (display *binary-main-c-template* port))
-            'replace)
-
-          (printf "==> [4/4] Compile + link -> ~a\n" output)
-          (let* ([extra-archives
-                  (apply string-append
-                    (map (lambda (a)
-                           (let ([p (format "~a/~a" csv-dir a)])
-                             (if (file-exists? p)
-                               (string-append " " (shell-quote p))
-                               "")))
-                         '("liblz4.a" "libz.a")))]
-                 [os-libs (machine-type->os-libs mt)]
-                 [cc-cmd (format "~a -I~a -I~a -O2 -o ~a ~a ~a~a ~a"
-                                 cc
-                                 (shell-quote obj-dir)
-                                 (shell-quote csv-dir)
-                                 (shell-quote output)
-                                 (shell-quote main-c)
-                                 (shell-quote libkernel)
-                                 extra-archives
-                                 os-libs)])
-            (printf "    ~a\n" cc-cmd)
-            (let ([rc (system cc-cmd)])
-              (unless (zero? rc)
-                (error 'jerbuild (format "binary: cc exited ~a" rc)))))
-
-          (system (format "rm -rf ~a" (shell-quote obj-dir)))
-          (printf "\n=== Build complete: ~a ===\n" output)))))))
+               (format "build: unexpected positional args ~a" rest)))
+      (let ([config-path
+             (cond
+               [(not (null? config-paths))
+                (let ([p (car (reverse config-paths))])
+                  (unless (file-exists? p)
+                    (error 'jerbuild (format "build: --config not found: ~a" p)))
+                  p)]
+               [else
+                (or (find-config-from (current-directory))
+                    (error 'jerbuild
+                           "build: no .jerbuild found in cwd or any parent"))])])
+        (printf "=== jerbuild build ===\n")
+        (printf "    Config: ~a\n" config-path)
+        (let-values ([(entry output libdirs cfg-cc rust-crates
+                       extra-sources extra-archives extra-ldflags)
+                      (load-jerbuild-config config-path)])
+          (do-binary-build libdirs
+                           (or cc-arg cfg-cc)
+                           extra-archives extra-sources extra-ldflags
+                           rust-crates
+                           (list entry output)))))))
 
 ;;;; ============================================================
 ;;;; Entry point
@@ -1641,6 +1958,8 @@ int main(int argc, const char *argv[]) {
      (run-compile (cdr args))]
     [(and (pair? args) (string=? (car args) "binary"))
      (run-binary (cdr args))]
+    [(and (pair? args) (string=? (car args) "build"))
+     (run-build (cdr args))]
     [(and (pair? args) (string=? (car args) "transpile"))
      (let-values ([(src-dir lib-dir) (parse-args (cdr args))])
        (jerbuild src-dir lib-dir))]
diff --git a/support/build-jerbuild.sh b/support/build-jerbuild.sh
index a2269a7..084e0b4 100755
--- a/support/build-jerbuild.sh
+++ b/support/build-jerbuild.sh
@@ -349,18 +349,23 @@ int main(int argc, const char *argv[]) {
             "  jerbuild compile --libdirs <p> [--wpo] FILE...\n"
             "                                             # compile-imported-libs\n"
             "  jerbuild exec --libdirs <p> FILE [args...] # load+run a Scheme script\n"
-            "  jerbuild binary [--libdirs <p>] [--cc CC] <entry.ss> <output>\n"
-            "                                             # build standalone binary\n"
+            "  jerbuild binary [--libdirs <p>] [--cc CC]\n"
+            "          [--extra-archive A] [--extra-source S.c]\n"
+            "          [--extra-ldflag F] [--rust-crate Cargo.toml[:features]]\n"
+            "          <entry.ss> <output>                # build standalone binary\n"
+            "  jerbuild build [--cc CC] [--config PATH]   # read .jerbuild, build\n"
             "  jerbuild --jerboa-home                     # extract+print stdlib path\n"
             "  jerbuild --version\n",
             stdout);
         return 0;
     }
 
-    /* The `binary` subcommand needs petite.boot/scheme.boot/libkernel.a/scheme.h
-     * on disk so the Scheme handler can embed + link them. Pre-extract the
-     * bundle and expose the path via env so the Scheme side can find them. */
-    if (argc >= 2 && strcmp(argv[1], "binary") == 0) {
+    /* The `binary` and `build` subcommands need petite.boot/scheme.boot/
+     * libkernel.a/scheme.h on disk so the Scheme handler can embed + link
+     * them. Pre-extract the bundle and expose the path via env so the
+     * Scheme side can find them. */
+    if (argc >= 2 && (strcmp(argv[1], "binary") == 0 ||
+                      strcmp(argv[1], "build")  == 0)) {
         setenv("JERBUILD_BUNDLE_DIR", ensure_extracted(), 1);
     }