Initial scaffold: signal-cli JSON-RPC bridge in Jerboa

ober

1a1a03a273c4e544285fcdcc5f05e5cb3c3b2fb0

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a143636
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+*.so
+*.wpo
+*.boot
+*.o
+signal-bin
+signal-macos
+signal-musl
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..0dd39fe
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,42 @@
+JERBOA_HOME ?= $(realpath $(CURDIR)/../jerboa)
+SCHEME ?= $(JERBOA_HOME)/.chez/bin/scheme
+BIN_DIR := $(HOME)/.local/bin
+
+.PHONY: run test install clean help
+
+.DEFAULT_GOAL := help
+
+run:
+	JERBOA_HOME=$(JERBOA_HOME) \
+		$(SCHEME) -q --libdirs $(CURDIR):$(JERBOA_HOME)/lib --script signal/main.ss -- $(ARGS)
+
+test:
+	JERBOA_HOME=$(JERBOA_HOME) \
+		$(SCHEME) -q --libdirs $(CURDIR):$(JERBOA_HOME)/lib --script test/test-signal.ss
+
+compile-check:
+	JERBOA_HOME=$(JERBOA_HOME) \
+		$(SCHEME) -q --libdirs $(CURDIR):$(JERBOA_HOME)/lib --script - <<< '(import (signal rpc))'
+
+install:
+	mkdir -p $(BIN_DIR)
+	printf '#!/bin/sh\nexec $(SCHEME) -q --libdirs $(CURDIR):$(JERBOA_HOME)/lib --script $(CURDIR)/signal/main.ss -- "$$@"\n' > $(BIN_DIR)/jerboa-signal
+	chmod +x $(BIN_DIR)/jerboa-signal
+	@echo "Installed jerboa-signal to $(BIN_DIR)/jerboa-signal"
+
+clean:
+	find signal -name '*.so' -delete
+	find signal -name '*.wpo' -delete
+
+help:
+	@echo "jerboa-signal -- Signal client over signal-cli"
+	@echo ""
+	@echo "Targets:"
+	@echo "  run ARGS='<args>'   Run jerboa-signal with arguments"
+	@echo "  test                Run tests"
+	@echo "  install             Install jerboa-signal launcher to ~/.local/bin"
+	@echo "  clean               Remove build artifacts"
+	@echo ""
+	@echo "Prerequisite: signal-cli must be linked to your Signal account."
+	@echo "  signal-cli link -n 'jerboa-signal'"
+	@echo "  (then scan/paste the printed sgnl://linkdevice URI on your phone)"
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..686a792
--- /dev/null
+++ b/README.md
@@ -0,0 +1,75 @@
+# jerboa-signal
+
+A Signal client written in [Jerboa](https://git.sr.ht/~lisp/jerboa), driving
+[signal-cli](https://github.com/AsamK/signal-cli) (Java) as a JSON-RPC
+subprocess. All orchestration, arg parsing, and message routing is Jerboa;
+the Signal protocol itself is delegated to signal-cli's `libsignal-service-java`.
+
+## What it does
+
+Bidirectional bridge between Signal and your shell:
+
+```
+jerboa-signal send [-a +PHONE] RECIPIENT MESSAGE   # send one message, exit
+jerboa-signal listen [-a +PHONE]                   # stream inbound as NDJSON
+```
+
+`-a` is required only if multiple Signal accounts are linked to signal-cli.
+
+## Prerequisites
+
+- `signal-cli` on `PATH` (Homebrew: `brew install signal-cli`)
+- A Jerboa checkout next to this repo at `../jerboa`, or `JERBOA_HOME` set
+
+## One-time setup: link as a secondary device
+
+jerboa-signal expects signal-cli to already be linked. Run this once:
+
+```sh
+signal-cli link -n "jerboa-signal"
+```
+
+It prints a `sgnl://linkdevice?uuid=...` URI. On your phone:
+**Signal → Settings → Linked devices → "+" → scan/paste the URI**. signal-cli
+will register the device and exit. Verify:
+
+```sh
+signal-cli listAccounts
+```
+
+## Usage
+
+```sh
+make install                                   # installs ~/.local/bin/jerboa-signal
+jerboa-signal send +15551234567 "hello world"
+jerboa-signal listen | jq .                    # pretty-print incoming envelopes
+```
+
+Without `make install`, run via the Makefile:
+
+```sh
+make run ARGS="send +15551234567 hello"
+make run ARGS="listen"
+```
+
+## Architecture
+
+```
+┌─────────────────┐  argv      ┌──────────────────┐  NDJSON  ┌────────────┐
+│ jerboa-signal   │ ─────────► │ signal/main.ss   │ ───────► │ signal-cli │
+│   (CLI argv)    │            │  (subcommands)   │          │  jsonRpc   │
+└─────────────────┘            └──────────────────┘ ◄─────── └────────────┘
+                                        │           NDJSON          │
+                                        ▼                           ▼
+                                  stdout / stderr            Signal servers
+```
+
+Modules:
+- `signal/main.ss` — entry point, argv parsing, subcommand dispatch
+- `signal/rpc.ss`  — JSON-RPC client over signal-cli's stdin/stdout (NDJSON framing, id correlation, notification queue)
+- `signal/cmd-send.ss` — `send` subcommand
+- `signal/cmd-listen.ss` — `listen` subcommand
+
+## License
+
+ISC.
diff --git a/signal/cmd-listen.ss b/signal/cmd-listen.ss
new file mode 100644
index 0000000..690c330
--- /dev/null
+++ b/signal/cmd-listen.ss
@@ -0,0 +1,49 @@
+#!chezscheme
+;;; signal/cmd-listen -- `listen` subcommand.
+;;;
+;;; Usage: jerboa-signal listen [-a +PHONE]
+;;;
+;;; Streams incoming Signal envelopes as NDJSON on stdout. Each line is
+;;; the `params` object from a signal-cli "receive" notification.
+
+(library (signal cmd-listen)
+  (export cmd-listen)
+
+  (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?)
+          (std text json)
+          (signal rpc))
+
+  (def (cmd-listen account)
+    (let ([sc (spawn-signal-cli account)])
+      (dynamic-wind
+        (lambda () (void))
+        (lambda ()
+          (let loop ()
+            (let ([n (next-notification sc)])
+              (cond
+                [(eof-object? n)
+                 (display "signal-cli stream ended\n" (current-error-port))]
+                [else
+                 (emit-envelope n)
+                 (loop)]))))
+        (lambda () (close-signal-cli sc)))))
+
+  (def (emit-envelope notif)
+    ;; signal-cli emits notifications with method "receive"; payload is in
+    ;; `params`. We unwrap to keep stdout consumers from caring about the
+    ;; JSON-RPC envelope.
+    (let ([params (and (hashtable? notif) (hashtable-ref notif "params" #f))])
+      (display (json-object->string (or params notif)))
+      (newline)
+      (flush-output-port (current-output-port))))
+
+  ) ;; end library
diff --git a/signal/cmd-send.ss b/signal/cmd-send.ss
new file mode 100644
index 0000000..4296a92
--- /dev/null
+++ b/signal/cmd-send.ss
@@ -0,0 +1,51 @@
+#!chezscheme
+;;; signal/cmd-send -- `send` subcommand.
+;;;
+;;; Usage: jerboa-signal send [-a +PHONE] RECIPIENT MESSAGE
+;;;
+;;; RECIPIENT is either a phone number (+15551234567) or a Signal group id.
+;;; This wraps signal-cli's `send` JSON-RPC method.
+
+(library (signal cmd-send)
+  (export cmd-send)
+
+  (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?)
+          (std text json)
+          (signal rpc))
+
+  (def (cmd-send account recipient message)
+    (let ([sc (spawn-signal-cli account)])
+      (dynamic-wind
+        (lambda () (void))
+        (lambda ()
+          (let* ([params (make-send-params recipient message)]
+                 [result (rpc-call sc "send" params)])
+            (display (json-object->string result))
+            (newline)))
+        (lambda () (close-signal-cli sc)))))
+
+  (def (make-send-params recipient message)
+    (let ([p (make-hashtable equal-hash equal?)])
+      ;; signal-cli accepts `recipient` as a list of phone numbers / UUIDs,
+      ;; or `groupId` for groups. We auto-detect by leading char.
+      (cond
+        [(and (string? recipient)
+              (> (string-length recipient) 0)
+              (char=? (string-ref recipient 0) #\+))
+         (hashtable-set! p "recipient" (list recipient))]
+        [else
+         ;; Treat as group id if it doesn't look like a phone number.
+         (hashtable-set! p "groupId" recipient)])
+      (hashtable-set! p "message" message)
+      p))
+
+  ) ;; end library
diff --git a/signal/main.ss b/signal/main.ss
new file mode 100644
index 0000000..1923c1c
--- /dev/null
+++ b/signal/main.ss
@@ -0,0 +1,103 @@
+#!chezscheme
+;;; jerboa-signal -- entry point.
+;;;
+;;; Drives signal-cli (Java) as a JSON-RPC subprocess. Bidirectional bridge:
+;;;   jerboa-signal send [-a +PHONE] RECIPIENT MESSAGE   -- send one message
+;;;   jerboa-signal listen [-a +PHONE]                   -- stream inbound as NDJSON
+;;;
+;;; Prerequisite: signal-cli must already be linked to your account
+;;;   signal-cli link -n "jerboa-signal"
+;;; then scan/paste the printed sgnl://linkdevice URI on your phone.
+
+(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))
+
+(define home        (or (getenv "HOME") "."))
+(define jerboa-dir  (or (getenv "JERBOA_HOME")
+                        (string-append home "/mine/jerboa")))
+(define project-dir (or (getenv "JERBOA_SIGNAL_DIR")
+                        (current-directory)))
+
+(library-directories
+  (append
+    (list (cons project-dir project-dir)
+          (cons (string-append jerboa-dir "/lib")
+                (string-append jerboa-dir "/lib")))
+    (library-directories)))
+
+(import (except (jerboa prelude) meta atom?)
+        (signal cmd-send)
+        (signal cmd-listen))
+
+(def *version* "0.1.0")
+
+(def (usage)
+  (display
+"Usage: jerboa-signal SUBCOMMAND [OPTIONS]
+
+Subcommands:
+  send [-a +PHONE] RECIPIENT MESSAGE   Send a Signal message
+  listen [-a +PHONE]                   Stream inbound envelopes as NDJSON
+  version                              Print version
+  help                                 Print this help
+
+RECIPIENT is either a phone number (+15551234567) or a Signal group id.
+
+-a is required only if multiple signal-cli accounts are linked. Set up
+linking once before using this tool:
+
+  signal-cli link -n 'jerboa-signal'
+  # then scan/paste the printed sgnl://linkdevice URI on your phone.
+"))
+
+(def (parse-account-flag args)
+  ;; Returns (values account remaining-args).
+  ;; Recognizes `-a VALUE` or `--account VALUE` anywhere in the first two slots.
+  (cond
+    [(and (pair? args)
+          (pair? (cdr args))
+          (or (string=? (car args) "-a") (string=? (car args) "--account")))
+     (values (cadr args) (cddr args))]
+    [else (values #f args)]))
+
+(def (main argv)
+  (cond
+    [(null? argv) (usage) (exit 2)]
+    [else
+     (let ([sub (car argv)]
+           [rest (cdr argv)])
+       (cond
+         [(or (string=? sub "help") (string=? sub "--help") (string=? sub "-h"))
+          (usage) (exit 0)]
+         [(or (string=? sub "version") (string=? sub "--version"))
+          (display "jerboa-signal ") (display *version*) (newline) (exit 0)]
+         [(string=? sub "send")
+          (let-values ([(account args) (parse-account-flag rest)])
+            (cond
+              [(< (length args) 2)
+               (display "jerboa-signal: send requires RECIPIENT and MESSAGE\n"
+                        (current-error-port))
+               (exit 2)]
+              [else
+               (cmd-send account (car args) (cadr args))]))]
+         [(string=? sub "listen")
+          (let-values ([(account args) (parse-account-flag rest)])
+            (cmd-listen account))]
+         [else
+          (display "jerboa-signal: unknown subcommand: " (current-error-port))
+          (display sub (current-error-port))
+          (newline (current-error-port))
+          (usage)
+          (exit 2)]))]))
+
+(main (let ([args (cdr (command-line))])
+        (if (and (pair? args) (string=? (car args) "--"))
+          (cdr args)
+          args)))
diff --git a/signal/rpc.ss b/signal/rpc.ss
new file mode 100644
index 0000000..daba79e
--- /dev/null
+++ b/signal/rpc.ss
@@ -0,0 +1,154 @@
+#!chezscheme
+;;; signal/rpc -- JSON-RPC client over signal-cli's `jsonRpc` subcommand.
+;;;
+;;; signal-cli reads NDJSON (one JSON object per line) on stdin and writes
+;;; NDJSON on stdout. Each outbound line is either a response to one of our
+;;; requests (correlated by `id`) or a notification (no `id`) carrying a
+;;; received Signal envelope.
+
+(library (signal rpc)
+  (export
+    spawn-signal-cli
+    close-signal-cli
+    signal-cli?
+    signal-cli-pid
+
+    rpc-call
+
+    next-notification
+    drain-notifications)
+
+  (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?)
+          (std misc process)
+          (std text json))
+
+  ;; --- Process handle ---
+  ;; proc          : process-port-rec from (std misc process)
+  ;; next-id-box   : box holding the next JSON-RPC request id
+  ;; pending       : reserved for future async correlation
+  ;; notif-box     : box holding FIFO queue of pending notifications
+  (defstruct signal-cli (proc next-id-box pending notif-box))
+
+  (def (spawn-signal-cli account)
+    ;; Spawn `signal-cli [-a ACCOUNT] jsonRpc`. Pass #f for account if only
+    ;; one is linked.
+    (let* ([cmd  (if account
+                   (list "signal-cli" "-a" account "jsonRpc")
+                   (list "signal-cli" "jsonRpc"))]
+           [proc (open-process cmd)])
+      (make-signal-cli proc (box 1) (make-hashtable equal-hash equal?) (box '()))))
+
+  (def (signal-cli-pid sc)
+    (process-port-pid (signal-cli-proc sc)))
+
+  (def (close-signal-cli sc)
+    (let ([proc (signal-cli-proc sc)])
+      (close-port (process-port-rec-stdin-port proc))
+      (process-kill (process-port-pid proc) 15)
+      (void)))
+
+  ;; --- Wire IO ---
+
+  (def (write-frame sc obj)
+    (let ([port (process-port-rec-stdin-port (signal-cli-proc sc))])
+      (display (json-object->string obj) port)
+      (newline port)
+      (flush-output-port port)))
+
+  (def (read-frame sc)
+    (let* ([port (process-port-rec-stdout-port (signal-cli-proc sc))]
+           [line (get-line port)])
+      (if (eof-object? line)
+        line
+        (string->json-object line))))
+
+  ;; --- Request/response correlation ---
+
+  (def (next-id! sc)
+    (let* ([b (signal-cli-next-id-box sc)]
+           [n (unbox b)])
+      (set-box! b (+ n 1))
+      n))
+
+  (def (make-rpc-request id method params)
+    (let ([h (make-hashtable equal-hash equal?)])
+      (hashtable-set! h "jsonrpc" "2.0")
+      (hashtable-set! h "id" id)
+      (hashtable-set! h "method" method)
+      (when params (hashtable-set! h "params" params))
+      h))
+
+  (def (rpc-response-for? msg id)
+    (and (hashtable? msg)
+         (let ([msg-id (hashtable-ref msg "id" #f)])
+           (and msg-id (equal? msg-id id)))))
+
+  (def (rpc-notification? msg)
+    (and (hashtable? msg)
+         (not (hashtable-ref msg "id" #f))
+         (hashtable-ref msg "method" #f)))
+
+  (def (rpc-response-result-or-raise msg)
+    (let ([err (hashtable-ref msg "error" #f)])
+      (if err
+        (error 'rpc-call
+               (if (hashtable? err)
+                 (or (hashtable-ref err "message" #f) "rpc error")
+                 "rpc error")
+               err)
+        (hashtable-ref msg "result" #f))))
+
+  (def (enqueue-notification! sc msg)
+    (let* ([b (signal-cli-notif-box sc)]
+           [cur (unbox b)])
+      (set-box! b (append cur (list msg)))))
+
+  (def (rpc-call sc method params)
+    (let* ([id  (next-id! sc)]
+           [req (make-rpc-request id method params)])
+      (write-frame sc req)
+      (let loop ()
+        (let ([msg (read-frame sc)])
+          (cond
+            [(eof-object? msg)
+             (error 'rpc-call "signal-cli closed stdout before responding")]
+            [(rpc-response-for? msg id)
+             (rpc-response-result-or-raise msg)]
+            [(rpc-notification? msg)
+             (enqueue-notification! sc msg)
+             (loop)]
+            [else (loop)])))))
+
+  (def (next-notification sc)
+    (let loop ()
+      (let* ([b (signal-cli-notif-box sc)]
+             [q (unbox b)])
+        (cond
+          [(pair? q)
+           (set-box! b (cdr q))
+           (car q)]
+          [else
+           (let ([msg (read-frame sc)])
+             (cond
+               [(eof-object? msg) msg]
+               [(rpc-notification? msg)
+                (enqueue-notification! sc msg)
+                (loop)]
+               [else (loop)]))]))))
+
+  (def (drain-notifications sc)
+    (let* ([b (signal-cli-notif-box sc)]
+           [q (unbox b)])
+      (set-box! b '())
+      q))
+
+  ) ;; end library