jpkg: install host tools without a project

ober

2e7988939e3f311ce879934bbe9b5f309e2aceb2

diff --git a/docs/jpkg-guide.md b/docs/jpkg-guide.md
index 3e576a9..9be89c3 100644
--- a/docs/jpkg-guide.md
+++ b/docs/jpkg-guide.md
@@ -83,8 +83,8 @@ the store   = ~/.jerboa/pkg — verified artifacts, shared across projects
 
 - `add` / `update` resolve ranges → write `jpkg.lock`.
 - Bare `install` reads `jpkg.lock` and only the lock — deterministic,
-  offline-friendly, CI-safe. `install PKG` is the explicit resolve-and-lock
-  convenience form.
+  offline-friendly, CI-safe. `install PKG` installs a signed executable
+  package into the user's bin directory without requiring a project.
 - Installing copies verified files; it never executes them. `build` runs
   code, in a sandbox, under policy.
 
@@ -211,7 +211,7 @@ Global: `jpkg --help`, `jpkg --version`. Every command also works as
 | `jpkg new NAME` | Scaffold a new package directory with manifest, strict policy, lockfile, `src/main.ss`, README, and `.build.yml`. |
 | `jpkg add PKG[@VERSION]` | Add a dependency (range), resolve, write lock, install. |
 | `jpkg remove PKG` | Drop a dependency, re-resolve, prune the environment. |
-| `jpkg install [PKG[@VERSION]]` | Install a package for this host, or with no argument install **exactly** `jpkg.lock`. |
+| `jpkg install [PKG[@VERSION]]` | Install a host-specific executable globally, or with no argument install **exactly** `jpkg.lock`. |
 | `jpkg update [PKG ...]` | Re-resolve to newer allowed versions. |
 | `jpkg uninstall PKG` | Remove a package from the project environment only. |
 | `jpkg list` | List locked packages. |
diff --git a/lib/std/pkg/commands.ss b/lib/std/pkg/commands.ss
index 8f0136e..3a4e3cf 100644
--- a/lib/std/pkg/commands.ss
+++ b/lib/std/pkg/commands.ss
@@ -16,7 +16,8 @@
           (only (jerboa core) def try catch)
           (only (std pkg util)
                 jpkg-error mkdir-p path-concat path-basename remove-tree
-                write-file-bytevector string-suffix-of? string-join-list)
+                read-file-bytevector write-file-bytevector
+                string-prefix-of? string-suffix-of? string-join-list)
           (only (std pkg manifest)
                 manifest-template parse-manifest-file
                 manifest-name manifest-version valid-package-name?)
@@ -32,7 +33,8 @@
           (only (std pkg project)
                 project-add project-remove project-install project-update
                 project-uninstall project-list project-env-paths
-                project-link project-unlink project-verify-lock)
+                project-link project-unlink project-verify-lock env-dir-name)
+          (only (std pkg store) jpkg-home)
           (only (std pkg ed25519) hex->bytes)
           (only (std pkg publish)
                 publish-keygen publish-load-key publish-key-public-hex
@@ -258,6 +260,48 @@
            (say "  ~a ~a (~a)" (locked-package-name p)
                 (locked-package-version p) (locked-package-registry p))))
      pkgs))
+  (def (global-bin-dir)
+    (or (getenv "JERBOA_INSTALL_DIR")
+        (let ([home (or (getenv "HOME")
+                        (jpkg-error "install: HOME not set"))])
+          (path-concat home ".local/bin"))))
+  (def (install-global-tool spec)
+    (unless (or (string-prefix-of? "jerboa-code" spec)
+                (string-prefix-of? "@ober/jerboa-code" spec))
+      (jpkg-error "install: package argument is only supported for jerboa-code"))
+    (let ([tools-dir (path-concat (jpkg-home) "tools")]
+          [original-dir (current-directory)])
+      (mkdir-p tools-dir)
+      (dynamic-wind
+        (lambda () (current-directory tools-dir))
+        (lambda ()
+          (unless (file-exists? "jpkg.sexp")
+            (cmd-init '("@ober/global-tools")))
+          (let* ([pkgs (project-add spec)]
+                 [jcode-pkg
+                  (let loop ([rest pkgs])
+                    (cond
+                      [(null? rest)
+                       (jpkg-error "install: resolved package has no jcode executable")]
+                      [(string-prefix-of? "@ober/jerboa-code-"
+                                          (locked-package-name (car rest)))
+                       (car rest)]
+                      [else (loop (cdr rest))]))]
+                 [source
+                  (path-concat
+                   (path-concat ".jpkg/deps"
+                                (env-dir-name
+                                 (locked-package-name jcode-pkg)))
+                   "jcode")]
+                 [bin-dir (global-bin-dir)]
+                 [destination (path-concat bin-dir "jcode")])
+            (unless (file-exists? source)
+              (jpkg-error "install: package does not contain jcode"))
+            (mkdir-p bin-dir)
+            (write-file-bytevector destination (read-file-bytevector source))
+            (chmod destination #o755)
+            pkgs))
+        (lambda () (current-directory original-dir)))))
 
   (def (cmd-add args)
     (unless (and (pair? args) (null? (cdr args)))
@@ -282,7 +326,7 @@
       (jpkg-error "usage: jpkg install [PKG[@VERSION]]"))
     (let ([pkgs (if (null? args)
                     (project-install)
-                    (project-add (car args)))])
+                    (install-global-tool (car args)))])
       (say "installed ~a package~a~a" (length pkgs)
            (if (= (length pkgs) 1) "" "s")
            (if (null? args) " from jpkg.lock" ""))
diff --git a/tests/test-jpkg-project.ss b/tests/test-jpkg-project.ss
index e339092..9e36823 100644
--- a/tests/test-jpkg-project.ss
+++ b/tests/test-jpkg-project.ss
@@ -57,6 +57,7 @@
 
 (mkdir-p world)
 (putenv "JERBOA_PKG_HOME" (path-concat world "pkghome"))
+(putenv "JERBOA_INSTALL_DIR" (path-concat world "bin"))
 (putenv "JERBOA_PKG_REGISTRIES" (format "main=~a" reg1))
 
 ;; build a package source dir + artifact, publish into registry
@@ -75,6 +76,11 @@
     (write-file-bytevector
      (path-concat dir "src/lib.ss")
      (string->utf8 (format ";; ~a ~a~%" name version)))
+    (when (s-contains? name "@ober/jerboa-code-")
+      (write-file-bytevector
+       (path-concat dir "jcode")
+       (string->utf8 "#!/bin/sh\nexit 0\n"))
+      (chmod (path-concat dir "jcode") #o755))
     (let-values ([(path digest size)
                   (pack-project dir (path-concat world "tmp.jpkg"))])
       (registry-add-package! reg path)
@@ -210,15 +216,12 @@
               (let ([r2 (run-jpkg '("install"))])
                 (and (= (car r2) 0)
                      (not (file-exists? ".jpkg/deps/lib-extra" #f)))))))
-(check "install-selects-host-package"
+(check "install-selects-host-global-tool"
        (let ([r (run-jpkg '("install" "jerboa-code@0.1.1"))])
          (and (= (car r) 0)
               (s-contains? (cadr r) host-jcode-package)
-              (equal? (map locked-package-name (lock-parse-file "jpkg.lock"))
-                      (list host-jcode-package))
-              (file-directory?
-               (path-concat ".jpkg/deps" (env-dir-name host-jcode-package)))
-              (= 0 (car (run-jpkg '("remove" "jerboa-code")))))))
+              (null? (lock-parse-file "jpkg.lock"))
+              (file-exists? (path-concat world "bin/jcode")))))
 
 ;; ── dependency-confusion guard ──────────────────────────────────────────
 ;; @lib/base gets locked from "main"; a higher-priority registry "evil"