Distribution: standalone binary, man page, shell completions

ober

66248060639e8f441ca7f5555e3eea5998f8a605

diff --git a/.gitignore b/.gitignore
index 4d91942..d9f8322 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,6 +16,11 @@ jpgp-musl.sha256
 jpgp-macos.sha256
 jpgp
 
+# Binary build intermediates
+*-main.c
+*.wp.so
+*_boot.h
+
 # Editor / OS
 .DS_Store
 *.swp
diff --git a/Makefile b/Makefile
index 56309f2..94a1634 100644
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,8 @@
 JERBOA_HOME ?= $(realpath $(CURDIR)/../jerboa)
 SCHEME      ?= $(JERBOA_HOME)/.chez/bin/scheme
 BIN_DIR     := $(HOME)/.local/bin
+LIB_DIR     := $(HOME)/.local/lib
+MAN_DIR     := $(HOME)/.local/share/man/man1
 
 NATIVE_DIR    := $(CURDIR)/pgp-native
 NATIVE_RELEASE := $(NATIVE_DIR)/target/release
@@ -10,7 +12,7 @@ else
   NATIVE_LIB := $(NATIVE_RELEASE)/libjpgp_native.so
 endif
 
-.PHONY: help run test test-interop build-native binary install clean
+.PHONY: help run test test-interop build-native binary install install-script clean
 .DEFAULT_GOAL := help
 
 help:
@@ -50,18 +52,52 @@ test-interop: build-native
 	JERBOA_HOME=$(JERBOA_HOME) JPGP_REPO=$(CURDIR) SCHEME=$(SCHEME) \
 		bash test/interop-gpg.sh
 
+BINARY_OUTPUT ?= jpgp-bin
+
 binary: build-native
-	@echo "Native binary build not yet implemented — use 'make run' or 'make install-script'"
+	@echo "==> Building self-contained $(BINARY_OUTPUT) binary"
+	JPGP_REPO=$(CURDIR) SCHEME=$(SCHEME) JERBOA_HOME=$(JERBOA_HOME) \
+	JERBOA_CHEZ_PREFIX=$(JERBOA_HOME)/.chez \
+		bash support/build-binary.sh support/binary-entry.ss $(BINARY_OUTPUT)
+	@echo ""
+	@echo "Built ./$(BINARY_OUTPUT). The native lib is still required at runtime:"
+	@echo "  $(NATIVE_LIB)"
+	@echo "  /usr/local/lib/libjpgp_native.{dylib,so}"
+	@echo "  /opt/homebrew/lib/libjpgp_native.{dylib,so}"
+	@echo "  or set JPGP_NATIVE_LIB=/path/to/libjpgp_native.{dylib,so}"
 
 install-script: build-native
-	mkdir -p $(BIN_DIR)
+	mkdir -p $(BIN_DIR) $(MAN_DIR)
 	printf '#!/bin/sh\nexec %s -q --libdirs %s:%s/lib --script %s/pgp/main.ss -- "$$@"\n' \
 		"$(SCHEME)" "$(CURDIR)" "$(JERBOA_HOME)" "$(CURDIR)" \
 		> $(BIN_DIR)/jpgp
 	chmod +x $(BIN_DIR)/jpgp
+	cp doc/jpgp.1 $(MAN_DIR)/jpgp.1
 	@echo "Installed jpgp launcher to $(BIN_DIR)/jpgp"
+	@echo "Installed man page to $(MAN_DIR)/jpgp.1"
+	@echo ""
+	@echo "Shell completions are in completions/ — copy to your distro's path:"
+	@echo "  bash: completions/jpgp.bash -> ~/.local/share/bash-completion/completions/jpgp"
+	@echo "  zsh : completions/_jpgp     -> any directory in \$$fpath"
+	@echo ""
 	@echo "(This wraps the dev interpreter; for a static binary see PLAN.md)"
 
+install: binary
+	mkdir -p $(BIN_DIR) $(LIB_DIR) $(MAN_DIR)
+	cp $(BINARY_OUTPUT)        $(BIN_DIR)/jpgp
+	chmod +x                   $(BIN_DIR)/jpgp
+	cp $(NATIVE_LIB)           $(LIB_DIR)/$(notdir $(NATIVE_LIB))
+	cp doc/jpgp.1              $(MAN_DIR)/jpgp.1
+	@echo ""
+	@echo "Installed:"
+	@echo "  $(BIN_DIR)/jpgp"
+	@echo "  $(LIB_DIR)/$(notdir $(NATIVE_LIB))"
+	@echo "  $(MAN_DIR)/jpgp.1"
+	@echo ""
+	@echo "Shell completions are in completions/ — copy to your distro's path:"
+	@echo "  bash: completions/jpgp.bash -> ~/.local/share/bash-completion/completions/jpgp"
+	@echo "  zsh : completions/_jpgp     -> any directory in \$$fpath"
+
 clean:
 	cd $(NATIVE_DIR) && cargo clean
 	find . -name '*.so' -not -path './pgp-native/*' -delete
diff --git a/README.md b/README.md
index 5773a4f..439a7bd 100644
--- a/README.md
+++ b/README.md
@@ -19,12 +19,15 @@ in OpenPGP format using [rPGP] — so the recipient can decrypt with plain
 
 ## Status
 
-Pre-alpha. v1 implements:
+v1 implements:
 
-- `keygen` / `pubkey`
-- `encrypt` / `decrypt` (age)
-- `encrypt -r FOO.asc` (OpenPGP outbound interop via rPGP)
-- `sign` / `verify` (Ed25519)
+- `keygen` / `pubkey` / `list` / `fingerprint` (SSH-style `SHA256:`)
+- `encrypt` / `decrypt` (age, multi-recipient, symmetric)
+- `encrypt -r FOO.asc` — OpenPGP outbound (rPGP, SEIPDv1)
+- `decrypt --pgp-key SECRET.asc` — OpenPGP inbound
+- `sign` / `verify` (Ed25519 native, `--pgp-key` produces OpenPGP detached sigs)
+- Self-contained `jpgp-bin` (Chez `compile-program` + boot-embedded)
+- Bidirectional gpg interop tested end-to-end (`make test-interop`)
 
 See `PLAN.md` for the design and out-of-scope items.
 
@@ -49,10 +52,24 @@ Requires Chez Scheme + [Jerboa] checked out at `~/mine/jerboa` (or set
 ```
 make run ARGS='version'   # interpreter mode
 make test                 # smoke tests
-make binary               # native `jpgp` binary
-make install              # → ~/.local/bin/jpgp
+make test-interop         # bidirectional gpg interop tests
+make binary               # native `jpgp` binary (5MB Mach-O / ELF)
+make install              # → ~/.local/bin/jpgp + ~/.local/lib/libjpgp_native
 ```
 
+## Interop with gpg
+
+`jpgp encrypt -r alice.asc -i secret.txt -o secret.asc` produces a SEIPDv1
+(RFC 4880) OpenPGP message that any version of gpg can decrypt.
+
+The reverse direction (`jpgp decrypt --pgp-key alice.sec.asc -i msg.asc`)
+works when the gpg recipient key does **not** advertise AEAD preferences
+in its self-signature. Modern GnuPG 2.5 defaults to its proprietary OCB
+packet (tag 20), which rPGP cannot parse. To produce a compatible key in
+gpg, drop AEAD from the default preferences (`features=0x05` rather than
+the default `0x07`). See `test/interop-gpg.sh` for a working
+`~/.gnupg/gpg.conf`.
+
 ## License
 
 ISC
diff --git a/completions/_jpgp b/completions/_jpgp
new file mode 100644
index 0000000..9896023
--- /dev/null
+++ b/completions/_jpgp
@@ -0,0 +1,86 @@
+#compdef jpgp
+# zsh completion for jpgp.
+# Install to a directory in your $fpath, e.g. /usr/local/share/zsh/site-functions/_jpgp,
+# or add `fpath+=(<dir-containing-_jpgp>)` before `compinit` in ~/.zshrc.
+
+_jpgp() {
+    local -a commands
+    commands=(
+        'keygen:Generate a new identity'
+        'pubkey:Print or write the public-key line'
+        'list:Show identity info'
+        'fingerprint:Print SHA256: fingerprint'
+        'encrypt:Encrypt data to recipients or a passphrase'
+        'decrypt:Decrypt age, jpgp, or OpenPGP data'
+        'sign:Produce a jpgp or OpenPGP signature'
+        'verify:Verify a jpgp or OpenPGP signature'
+        'version:Print version'
+        'help:Show help'
+    )
+
+    if (( CURRENT == 2 )); then
+        _describe 'command' commands
+        return
+    fi
+
+    local cmd="$words[2]"
+    case "$cmd" in
+        keygen)
+            _arguments \
+                '--out[output path]:identity file:_files' \
+                '-o[output path]:identity file:_files'
+            ;;
+        pubkey)
+            _arguments \
+                '--identity[identity file]:file:_files' \
+                '--out[output path]:file:_files' \
+                '-o[output path]:file:_files'
+            ;;
+        fingerprint|list)
+            _arguments \
+                '--identity[identity file]:file:_files'
+            ;;
+        encrypt)
+            _arguments \
+                '*-r[recipient]:recipient:_files' \
+                '*--recipient[recipient]:recipient:_files' \
+                '-i[input file]:file:_files' \
+                '--in[input file]:file:_files' \
+                '-o[output file]:file:_files' \
+                '--out[output file]:file:_files' \
+                '-s[symmetric mode]' \
+                '--symmetric[symmetric mode]'
+            ;;
+        decrypt)
+            _arguments \
+                '-i[input file]:file:_files' \
+                '--in[input file]:file:_files' \
+                '-o[output file]:file:_files' \
+                '--out[output file]:file:_files' \
+                '--identity[identity file]:file:_files' \
+                '--pgp-key[OpenPGP secret key]:file:_files' \
+                '--pgp-pass[OpenPGP passphrase]:passphrase' \
+                '-s[symmetric mode]' \
+                '--symmetric[symmetric mode]'
+            ;;
+        sign)
+            _arguments \
+                '-i[input file]:file:_files' \
+                '--in[input file]:file:_files' \
+                '-o[output file]:file:_files' \
+                '--out[output file]:file:_files' \
+                '--identity[identity file]:file:_files' \
+                '--pgp-key[OpenPGP secret key]:file:_files' \
+                '--pgp-pass[OpenPGP passphrase]:passphrase'
+            ;;
+        verify)
+            _arguments \
+                '-i[input file]:file:_files' \
+                '--in[input file]:file:_files' \
+                '--pubkey[jpgp1 pubkey]:file:_files' \
+                '--pgp-pubkey[OpenPGP pubkey]:file:_files' \
+                '*:signature file:_files'
+            ;;
+    esac
+}
+_jpgp "$@"
diff --git a/completions/jpgp.bash b/completions/jpgp.bash
new file mode 100644
index 0000000..e65dbc2
--- /dev/null
+++ b/completions/jpgp.bash
@@ -0,0 +1,59 @@
+# bash completion for jpgp
+# Source from ~/.bashrc or install to /etc/bash_completion.d/ (or your distro's path).
+
+_jpgp() {
+    local cur prev words cword
+    _init_completion || return
+
+    local commands="keygen pubkey list fingerprint encrypt decrypt sign verify version help"
+
+    if [[ $cword -eq 1 ]]; then
+        COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
+        return
+    fi
+
+    local cmd="${words[1]}"
+    case "$cmd" in
+        keygen)
+            COMPREPLY=( $(compgen -W "--out -o" -- "$cur") )
+            ;;
+        pubkey)
+            case "$prev" in
+                --identity|--out|-o) _filedir ; return ;;
+            esac
+            COMPREPLY=( $(compgen -W "--identity --out -o" -- "$cur") )
+            ;;
+        fingerprint|list)
+            case "$prev" in
+                --identity) _filedir ; return ;;
+            esac
+            COMPREPLY=( $(compgen -W "--identity" -- "$cur") )
+            ;;
+        encrypt)
+            case "$prev" in
+                -r|--recipient|-i|--in|-o|--out) _filedir ; return ;;
+            esac
+            COMPREPLY=( $(compgen -W "-r --recipient -i --in -o --out -s --symmetric" -- "$cur") )
+            ;;
+        decrypt)
+            case "$prev" in
+                -i|--in|-o|--out|--identity|--pgp-key) _filedir ; return ;;
+            esac
+            COMPREPLY=( $(compgen -W "-i --in -o --out --identity --pgp-key --pgp-pass -s --symmetric" -- "$cur") )
+            ;;
+        sign)
+            case "$prev" in
+                -i|--in|-o|--out|--identity|--pgp-key) _filedir ; return ;;
+            esac
+            COMPREPLY=( $(compgen -W "-i --in -o --out --identity --pgp-key --pgp-pass" -- "$cur") )
+            ;;
+        verify)
+            case "$prev" in
+                -i|--in|--pubkey|--pgp-pubkey) _filedir ; return ;;
+                verify) _filedir ; return ;;
+            esac
+            COMPREPLY=( $(compgen -W "-i --in --pubkey --pgp-pubkey" -- "$cur") )
+            ;;
+    esac
+}
+complete -F _jpgp jpgp
diff --git a/doc/jpgp.1 b/doc/jpgp.1
new file mode 100644
index 0000000..cbfe705
--- /dev/null
+++ b/doc/jpgp.1
@@ -0,0 +1,194 @@
+.Dd May 15, 2026
+.Dt JPGP 1
+.Os
+.Sh NAME
+.Nm jpgp
+.Nd modern PGP replacement with age + Ed25519 + OpenPGP interop
+.Sh SYNOPSIS
+.Nm
+.Ar command
+.Op Ar options
+.Sh DESCRIPTION
+.Nm
+is a small command-line tool for encrypting, signing, and verifying
+data.
+It uses
+.Xr age 1
+for native encryption,
+.Xr ed25519 7
+for native signatures, and rPGP for OpenPGP interoperability with
+.Xr gpg 1 .
+.Pp
+By default, identities live in
+.Pa ~/.jpgp/identity.age
+encrypted with a user-supplied passphrase.
+Each identity bundles one age secret key and one Ed25519 signing key.
+.Sh COMMANDS
+.Bl -tag -width "encrypt"
+.It Cm keygen Op Fl -out Ar PATH
+Generate a new identity.
+Prompts for a passphrase.
+.It Cm pubkey Oo Fl -identity Ar PATH Oc Oo Fl -out Ar PATH Oc
+Print the
+.Sq jpgp1
+public-key line for this identity.
+.It Cm fingerprint Op Fl -identity Ar PATH
+Print the SSH-style
+.Sq SHA256:
+fingerprint of this identity's public key line.
+.It Cm list Op Fl -identity Ar PATH
+Show identity paths, age pubkey, fingerprint, and full public line.
+.It Xo
+.Cm encrypt
+.Op Fl r Ar RECIPIENT ...
+.Op Fl s | -symmetric
+.Op Fl i Ar IN
+.Op Fl o Ar OUT
+.Xc
+Encrypt
+.Ar IN
+to one or more recipients.
+.Ar RECIPIENT
+may be a bare
+.Sq age1...
+string, a path to a
+.Sq jpgp1
+public-key line, or a path to an armored OpenPGP public key.
+Multiple
+.Fl r
+flags may be given for age/jpgp recipients (all share one ciphertext).
+OpenPGP recipients require a single
+.Fl r
+and produce gpg-compatible output (SEIPDv1 / RFC 4880).
+With
+.Fl -symmetric ,
+prompts for a passphrase instead of using recipient keys.
+.It Xo
+.Cm decrypt
+.Op Fl s | -symmetric
+.Op Fl i Ar IN
+.Op Fl o Ar OUT
+.Op Fl -identity Ar PATH
+.Op Fl -pgp-key Ar SECRET.asc
+.Op Fl -pgp-pass Ar PASS
+.Xc
+Decrypt
+.Ar IN .
+Defaults to identity mode (age payload).
+.Pp
+With
+.Fl -symmetric ,
+prompts for a passphrase.
+With
+.Fl -pgp-key ,
+decrypts an OpenPGP message using the armored secret key at the given
+path.
+.Nm
+parses SEIPDv1 packets only (the format gpg emits when its recipient
+keys do not advertise AEAD prefs); the gnupg-proprietary OCB packet
+.Pq packet 20
+is not supported.
+.It Xo
+.Cm sign
+.Op Fl i Ar IN
+.Op Fl o Ar OUT
+.Op Fl -identity Ar PATH
+.Op Fl -pgp-key Ar SECRET.asc
+.Op Fl -pgp-pass Ar PASS
+.Xc
+Sign
+.Ar IN
+with this identity.
+Default output is a
+.Sq jpgp
+signature blob over
+.Xr ed25519 7 .
+With
+.Fl -pgp-key ,
+produces a gpg-compatible detached OpenPGP signature.
+.It Xo
+.Cm verify
+.Ar SIG
+.Op Fl i Ar IN
+.Op Fl -pubkey Ar FILE
+.Op Fl -pgp-pubkey Ar PUB.asc
+.Xc
+Verify the signature at
+.Ar SIG
+against
+.Ar IN .
+The format is auto-detected by armor header:
+jpgp blobs are verified with the embedded pubkey
+.Pq or one supplied via Fl -pubkey ,
+and OpenPGP signatures with
+.Fl -pgp-pubkey .
+Exit status is 0 on success, 2 on bad signature.
+.It Cm version
+Print version.
+.El
+.Pp
+For every command,
+.Sq Fl i Sq -
+means stdin and
+.Sq Fl o Sq -
+means stdout.
+.Sh FILES
+.Bl -tag -width "~/.jpgp/identity.age"
+.It Pa ~/.jpgp/identity.age
+Default identity file (age-armored, passphrase-encrypted).
+.El
+.Sh EXAMPLES
+Generate an identity and print its fingerprint:
+.Bd -literal -offset indent
+$ jpgp keygen
+$ jpgp fingerprint
+SHA256:9p+rmTm...
+.Ed
+.Pp
+Encrypt a file to a friend's age recipient:
+.Bd -literal -offset indent
+$ jpgp encrypt -r age1xyz... -i secrets.txt -o secrets.age
+.Ed
+.Pp
+Encrypt to a colleague who uses gpg:
+.Bd -literal -offset indent
+$ jpgp encrypt -r colleague.asc -i memo.txt -o memo.asc
+.Ed
+.Pp
+Sign a release with an OpenPGP key, gpg-compatible:
+.Bd -literal -offset indent
+$ jpgp sign --pgp-key release.sec.asc \\
+            -i release.tar.gz -o release.tar.gz.asc
+.Ed
+.Sh EXIT STATUS
+.Ex -std
+A bad signature in
+.Cm verify
+exits with status 2.
+.Sh INTEROPERABILITY
+.Nm
+encrypts to OpenPGP recipients using SEIPDv1
+.Pq RFC 4880 .
+GnuPG 2.5 defaults to its proprietary OCB encrypted data packet
+.Pq packet 20 ,
+which rPGP cannot parse.
+For
+.Cm decrypt
+to work on a gpg-produced ciphertext, the recipient key's
+self-signature must not advertise AEAD support: either generate the gpg
+key with
+.Sq features
+byte
+.Sq 0x05
+.Pq instead of the default 0x07 ,
+or strip the
+.Sq pref-aead-algos
+subpacket from the key prefs.
+See
+.Pa test/interop-gpg.sh
+in the source tree for a working configuration.
+.Sh SEE ALSO
+.Xr age 1 ,
+.Xr gpg 1
+.Sh AUTHORS
+Written by Jaime Fournier and Claude.
diff --git a/pgp/ffi.ss b/pgp/ffi.ss
index faaa799..70360c8 100644
--- a/pgp/ffi.ss
+++ b/pgp/ffi.ss
@@ -79,13 +79,20 @@
 
   (define here (or (getenv "JPGP_DIR") (current-directory)))
 
+  ;; JPGP_NATIVE_LIB, if set, is tried first — used by the standalone
+  ;; binary to point at a sibling-installed dylib.
   (define *jpgp-lib-paths*
-    (list
-      (string-append here "/pgp-native/target/release/libjpgp_native" ext)
-      (string-append here "/pgp-native/target/debug/libjpgp_native"   ext)
-      (string-append "/usr/local/lib/libjpgp_native" ext)
-      (string-append "/opt/homebrew/lib/libjpgp_native" ext)
-      (string-append "libjpgp_native" ext)))
+    (let ([base
+            (list
+              (string-append here "/pgp-native/target/release/libjpgp_native" ext)
+              (string-append here "/pgp-native/target/debug/libjpgp_native"   ext)
+              (string-append "/usr/local/lib/libjpgp_native" ext)
+              (string-append "/opt/homebrew/lib/libjpgp_native" ext)
+              (string-append (or (getenv "HOME") ".") "/.local/lib/libjpgp_native" ext)
+              (string-append "libjpgp_native" ext))])
+      (cond
+        [(getenv "JPGP_NATIVE_LIB") => (lambda (p) (cons p base))]
+        [else base])))
 
   (define *jpgp-loaded?*
     (let loop ([ps *jpgp-lib-paths*])
diff --git a/pgp/util.ss b/pgp/util.ss
index e987a49..f9a930e 100644
--- a/pgp/util.ss
+++ b/pgp/util.ss
@@ -63,8 +63,12 @@
         (lambda (p) (put-bytevector p bv)))))
 
   (def (write-stdout-bytes bv)
-    (put-bytevector (current-output-port) bv)
-    (flush-output-port (current-output-port)))
+    ;; current-output-port is textual; standard-output-port is the
+    ;; underlying binary port. Use the binary port directly so we can
+    ;; write raw bytevectors.
+    (let ([p (standard-output-port (buffer-mode block))])
+      (put-bytevector p bv)
+      (flush-output-port p)))
 
   (def (string->bv s) (string->utf8 s))
   (def (bv->string bv) (utf8->string bv))
diff --git a/support/binary-entry.ss b/support/binary-entry.ss
new file mode 100644
index 0000000..65ead42
--- /dev/null
+++ b/support/binary-entry.ss
@@ -0,0 +1,26 @@
+#!chezscheme
+;;; binary-entry.ss — Entry point compiled into the static `jpgp` binary.
+;;;
+;;; Compiled by support/build-binary.sh into a single ELF/Mach-O binary that
+;;; bundles petite.boot, scheme.boot, and the whole-program-optimised jpgp
+;;; libraries. The native crypto remains in libjpgp_native.{dylib,so} and is
+;;; loaded at runtime — set JPGP_NATIVE_LIB to override the search.
+
+(import (except (chezscheme)
+                make-hash-table hash-table?
+                sort sort!
+                printf fprintf
+                path-extension path-absolute?
+                with-input-from-string with-output-to-string
+                iota 1+ 1-
+                partition
+                make-date make-time)
+        (except (jerboa prelude) meta atom?)
+        (pgp cli))
+
+;; Chez --script leaves a literal "--" in command-line-arguments when invoked
+;; with `-- $(ARGS)`. Strip it once here so the dispatcher sees clean argv.
+(let ([args (command-line-arguments)])
+  (run-cli (if (and (pair? args) (string=? (car args) "--"))
+             (cdr args)
+             args)))
diff --git a/support/build-binary.sh b/support/build-binary.sh
new file mode 100755
index 0000000..9f79699
--- /dev/null
+++ b/support/build-binary.sh
@@ -0,0 +1,154 @@
+#!/bin/sh
+# build-binary.sh — Build a self-contained jpgp binary.
+#
+# Thin wrapper around Jerboa's support/build-binary.sh that passes both
+# the project libdirs ($CURDIR) and the Jerboa lib so WPO can resolve all
+# imports. Native crypto (libjpgp_native.{dylib,so}) is still loaded at
+# runtime from /usr/local/lib, /opt/homebrew/lib, the build tree, or
+# $JPGP_NATIVE_LIB.
+
+set -eu
+
+JPGP_REPO="${JPGP_REPO:-$(cd "$(dirname "$0")/.." && pwd)}"
+JERBOA_HOME="${JERBOA_HOME:-$(cd "$JPGP_REPO/../jerboa" && pwd)}"
+SCHEME="${SCHEME:-$JERBOA_HOME/.chez/bin/scheme}"
+JERBOA_CHEZ_PREFIX="${JERBOA_CHEZ_PREFIX:-$JERBOA_HOME/.chez}"
+
+ENTRY="${1:-$JPGP_REPO/support/binary-entry.ss}"
+OUTPUT="${2:-jpgp-bin}"
+
+# Detect OS and toolchain (mirrors Jerboa's build-binary.sh exactly).
+OS=$(uname -s)
+case "$OS" in
+    FreeBSD) CC_DEFAULT=cc;  OS_LIBS="-lm -lpthread -lncurses -L/usr/local/lib -liconv" ;;
+    Darwin)  CC_DEFAULT=cc;  OS_LIBS="-lm -lpthread -lncurses -liconv" ;;
+    Linux)
+        CC_DEFAULT=gcc
+        if [ -f /usr/lib/x86_64-linux-gnu/libtinfo.so ] || [ -f /usr/lib64/libtinfo.so ]; then
+            OS_LIBS="-lm -ldl -lpthread -ltinfo"
+        else
+            OS_LIBS="-lm -ldl -lpthread -lncurses"
+        fi
+        ;;
+    *) CC_DEFAULT=cc; OS_LIBS="-lm -lpthread -lncurses" ;;
+esac
+CC="${CC:-$CC_DEFAULT}"
+
+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
+if [ -z "$CSV_DIR" ]; then
+    echo "ERROR: cannot find Chez install for machine-type '$MACHINE_TYPE'." >&2
+    exit 1
+fi
+
+echo "=== jpgp binary build: $ENTRY -> $OUTPUT ==="
+echo "    OS:    $OS"
+echo "    CC:    $CC"
+echo "    Chez:  $CSV_DIR"
+echo ""
+
+# Step 1: WPO compile — needs BOTH $JPGP_REPO (for (pgp ...) libs) and
+# $JERBOA_HOME/lib (for (jerboa prelude), (std ...), etc.).
+WPO_SO="${OUTPUT}.wp.so"
+OBJ_DIR=$(mktemp -d "/tmp/jpgp-bin-obj.XXXXXX")
+trap 'rm -rf "$OBJ_DIR" "$WPO_SO" petite_boot.h scheme_boot.h program_boot.h "${OUTPUT}-main.c"' EXIT
+
+echo "==> [1/4] WPO compile"
+"$SCHEME" --libdirs "$JPGP_REPO:$JERBOA_HOME/lib" \
+    --script "$JERBOA_HOME/support/build-boot.ss" "$ENTRY" "$WPO_SO" "$OBJ_DIR"
+echo ""
+
+# Step 2: embed boot files
+echo "==> [2/4] Embedding boot files 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
+echo ""
+
+# Step 3: generate main.c
+echo "==> [3/4] Generate ${OUTPUT}-main.c"
+cat > "${OUTPUT}-main.c" <<'CMAIN'
+/* jpgp binary entry point */
+#include "scheme.h"
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <fcntl.h>
+#include <sys/types.h>
+
+#include "petite_boot.h"
+#include "scheme_boot.h"
+#include "program_boot.h"
+
+static const char *write_program_tmpfile(void) {
+    static char path[] = "/tmp/jpgp-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[]) {
+    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();
+    Sscheme_program(prog_path, argc, argv);
+    unlink(prog_path);
+
+    Sscheme_deinit();
+    return 0;
+}
+CMAIN
+echo ""
+
+# Step 4: compile + link
+echo "==> [4/4] Compile + link -> $OUTPUT"
+EXTRA_ARCHIVES=""
+for a in liblz4.a libz.a; do
+    if [ -f "$CSV_DIR/$a" ]; then
+        EXTRA_ARCHIVES="$EXTRA_ARCHIVES $CSV_DIR/$a"
+    fi
+done
+
+# shellcheck disable=SC2086
+$CC -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