jerboa: add single-file-package format (exec command + header parser)

ober

fb22e5080e67416e79fde6bd5c2318125347029b

diff --git a/bin/jerboa b/bin/jerboa
index 86caa08..326cc77 100755
--- a/bin/jerboa
+++ b/bin/jerboa
@@ -31,6 +31,8 @@ Commands:
   (no args)          Launch the Jerboa REPL
   repl               Launch the Jerboa REPL
   run <file>         Run a Scheme script
+  exec <file>        Run a self-contained script with a requires: header
+                     (auto-installs declared dependencies before running)
   eval '<expr>'      Evaluate a single expression
   test [dir]         Discover and run test files (default: tests/)
   build              Run make build in the project directory
@@ -69,6 +71,120 @@ cmd_run() {
     exec "$SCHEME" --libdirs "$LIBDIRS" --script "$file"
 }
 
+##
+## parse_requires_header <file>
+##
+## Extract dependency URLs from a ";;; jerboa-package" header block.
+## The header lives at the top of the file and looks like:
+##
+##     ;;; jerboa-package
+##     ;;; name:    my-app
+##     ;;; requires:
+##     ;;;   github.com/alice/jerboa-foo
+##     ;;;   github.com/bob/jerboa-bar
+##     ;;;
+##
+## The block ends at the first line that is not a ";;;" comment, or at a
+## bare ";;;" (blank comment) after the requires: key. Unknown keys are
+## ignored. Any line that isn't "requires:" or an entry under it is
+## skipped.
+##
+## Prints one URL per line. Prints nothing if no header is present.
+parse_requires_header() {
+    awk '
+        BEGIN { in_block = 0; in_requires = 0 }
+        # A line like ";;;" or ";;; " ends the requires sub-block.
+        /^;;;[[:space:]]*$/ {
+            if (in_block) { in_requires = 0; next }
+            exit
+        }
+        # Non-comment line: header is over.
+        !/^;;;/ {
+            if (in_block) exit
+            next
+        }
+        # Header marker
+        /^;;;[[:space:]]*jerboa-package[[:space:]]*$/ { in_block = 1; next }
+        # Only process comments inside the block
+        in_block == 0 { next }
+        # Key: value line
+        /^;;;[[:space:]]*[A-Za-z_-]+:[[:space:]]*/ {
+            # "requires:" with no value starts a sub-block
+            if ($0 ~ /^;;;[[:space:]]*requires:[[:space:]]*$/) {
+                in_requires = 1
+                next
+            }
+            # Any other key: line exits the requires sub-block
+            in_requires = 0
+            next
+        }
+        # Entry line under requires:
+        in_requires == 1 {
+            line = $0
+            sub(/^;;;[[:space:]]*/, "", line)
+            sub(/[[:space:]]+$/, "", line)
+            if (line != "") print line
+        }
+    ' "$1"
+}
+
+cmd_exec() {
+    if [ $# -eq 0 ]; then
+        echo "Error: jerboa exec requires a file argument" >&2
+        echo "Usage: jerboa exec <file>" >&2
+        exit 1
+    fi
+    local file="$1"
+    shift
+    if [ ! -f "$file" ]; then
+        echo "Error: file not found: $file" >&2
+        exit 1
+    fi
+
+    local -a deps=()
+    while IFS= read -r dep; do
+        [ -n "$dep" ] && deps+=("$dep")
+    done < <(parse_requires_header "$file")
+
+    if [ ${#deps[@]} -gt 0 ]; then
+        local -a missing=()
+        for dep in "${deps[@]}"; do
+            local name
+            name="$(basename "$dep")"
+            if ! "$SCHEME" --libdirs "$LIBDIRS" --program <(cat <<CHECK
+(import (jerboa registry))
+(exit (if (package-installed? "$name") 0 1))
+CHECK
+            ) >/dev/null 2>&1; then
+                missing+=("$dep")
+            fi
+        done
+
+        if [ ${#missing[@]} -gt 0 ]; then
+            echo "jerboa exec: $file declares ${#missing[@]} missing package(s):"
+            for dep in "${missing[@]}"; do
+                echo "  - $dep"
+            done
+            if [ "${JERBOA_EXEC_YES:-}" != "1" ]; then
+                printf "Install now? [y/N] "
+                read -r reply
+                case "$reply" in
+                    y|Y|yes|YES) ;;
+                    *) echo "Aborted." >&2; exit 1 ;;
+                esac
+            fi
+            for dep in "${missing[@]}"; do
+                "$0" install "$dep" || {
+                    echo "Failed to install $dep" >&2
+                    exit 1
+                }
+            done
+        fi
+    fi
+
+    exec "$SCHEME" --libdirs "$LIBDIRS" --script "$file" "$@"
+}
+
 cmd_eval() {
     if [ $# -eq 0 ]; then
         echo "Error: jerboa eval requires an expression" >&2
@@ -225,6 +341,10 @@ case "${1:-}" in
         shift
         cmd_run "$@"
         ;;
+    exec)
+        shift
+        cmd_exec "$@"
+        ;;
     eval)
         shift
         cmd_eval "$@"
diff --git a/docs/single-file-packages.md b/docs/single-file-packages.md
new file mode 100644
index 0000000..22eaec5
--- /dev/null
+++ b/docs/single-file-packages.md
@@ -0,0 +1,113 @@
+# Single-file Jerboa packages
+
+Most `.ss` scripts are just "run it with `scheme --script`" — they depend
+only on the Jerboa standard library. But sometimes a script wants an
+external package (e.g., a Jerboa binding for some C library on GitHub).
+Traditionally that requires the reader to:
+
+1. Find the package.
+2. `jerboa install github.com/someone/jerboa-foo`.
+3. *Then* run the script.
+
+The **single-file package format** eliminates step 1-2 for the reader:
+the script declares its dependencies in a machine-readable header, and
+`jerboa exec` installs anything missing before running. LLMs can emit
+complete, runnable programs in a single file.
+
+## Format
+
+Put a block of `;;;` comments at the top of the file, starting with the
+marker `;;; jerboa-package`:
+
+```scheme
+#!/usr/bin/env jerboa
+;;; jerboa-package
+;;; name:    my-app
+;;; version: 0.1.0
+;;; requires:
+;;;   github.com/alice/jerboa-fancy-json
+;;;   github.com/bob/jerboa-http-ext
+;;;
+;;; Free-form description can follow an empty ;;;  comment —
+;;; these lines are ignored by the parser.
+(import (jerboa prelude))
+(import (fancy-json))
+;; ...rest of script
+```
+
+### Rules
+
+1. The header must begin at the first `;;; jerboa-package` line.
+2. Keys take the form `key: value` inside `;;; ` comments. Recognised
+   keys: `name`, `version`, `requires`.
+3. `requires:` is special — it is followed by indented `;;;   url`
+   lines, one package URL per line. The sub-block ends at a bare
+   `;;;` (blank comment) or at the next recognised key.
+4. The header block ends at the first non-`;;;` line.
+5. Unknown keys are ignored (forwards-compatible).
+
+## Running
+
+```bash
+jerboa exec my-app.ss
+```
+
+On first run, `jerboa exec` parses the header, compares the required
+packages against `jerboa list`, and for each missing one prompts:
+
+```
+jerboa exec: my-app.ss declares 2 missing package(s):
+  - github.com/alice/jerboa-fancy-json
+  - github.com/bob/jerboa-http-ext
+Install now? [y/N]
+```
+
+On `y` it installs each via `jerboa install` and then runs the script.
+Set `JERBOA_EXEC_YES=1` to skip the prompt (useful for CI or shebang
+scripts).
+
+## Shebang trick
+
+With the executable bit set and `jerboa` on `PATH`:
+
+```scheme
+#!/usr/bin/env -S jerboa exec
+;;; jerboa-package
+;;; name: hello
+;;; requires:
+;;;   github.com/alice/jerboa-greetings
+;;;
+(import (jerboa prelude) (greetings))
+(greet "world")
+```
+
+```bash
+chmod +x hello.ss
+./hello.ss
+```
+
+Note: `-S` is needed on Linux so `env` forwards two arguments to
+`jerboa`. On systems without `env -S`, use an explicit two-line shell
+wrapper instead.
+
+## Why not a separate manifest file?
+
+Scheme projects with complex layouts still want proper `package.sls` /
+Makefile-style manifests. This format targets the other end of the
+spectrum: **scripts small enough to live in one file** — exactly the
+kind of artifact LLMs produce. A scattered-file layout is friction for
+casual sharing via gist, paste, or email.
+
+## Comparison
+
+| Tool     | Format                                  |
+|----------|-----------------------------------------|
+| Deno     | `// deno-types="..."` in source        |
+| Go       | `// +build ...` comments               |
+| Python   | `# /// script` PEP-723 block           |
+| Jerboa   | `;;; jerboa-package` comment block     |
+
+All four live at the top of a `.ss` / `.ts` / `.go` / `.py` file, use
+comment syntax native to the host language, and are parsed by a
+separate runner that resolves dependencies before invoking the
+interpreter/compiler.