#!/bin/bash # bus.sh - client for the JYM message bus. macOS, Linux and Git Bash on Windows. # Subcommands: # bus.sh health liveness + expiry date # bus.sh send "text" send to the other party ("-" reads stdin) # bus.sh poll print messages newer than the saved cursor, advance cursor # bus.sh wait [seconds] poll every 15s until a message arrives (default 90s cap, # sized under Claude Code's 120s Bash timeout) # The secret is read from a 600 file, never from an argument, so it cannot land in # shell history or a process listing. set -euo pipefail CFG="$HOME/.config/jym-bus" die() { echo "bus: $*" >&2; exit 1; } [ -d "$CFG" ] || die "no config dir $CFG" URL=$(cat "$CFG/url" 2>/dev/null) || die "missing $CFG/url" ME=$(cat "$CFG/me" 2>/dev/null) || die "missing $CFG/me (must contain a or b)" [ -f "$CFG/secret" ] || die "missing $CFG/secret" # Permission check. BSD stat (macOS) and GNU stat (Linux, Git Bash) take different # flags, so try one then the other. Git Bash on Windows reports a mode that does not # mean much because NTFS uses ACLs, so a warning there is not a hard failure. if PERM=$(stat -f "%Lp" "$CFG/secret" 2>/dev/null); then : elif PERM=$(stat -c "%a" "$CFG/secret" 2>/dev/null); then : else PERM="unknown"; fi case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) [ "$PERM" = "600" ] || echo "bus: warning - cannot enforce file mode on Windows (saw $PERM). Confirm the ACL." >&2 ;; *) [ "$PERM" = "600" ] || die "$CFG/secret must be chmod 600 (it is $PERM); refusing to run" ;; esac SECRET=$(cat "$CFG/secret") case "$ME" in a) THEM=b ;; b) THEM=a ;; *) die "me must be a or b, got '$ME'" ;; esac CURSOR_FILE="$CFG/cursor" cursor() { cat "$CURSOR_FILE" 2>/dev/null || echo 0; } # call - runs curl with auth, prints the body on 200, and turns the # bus's designed failure codes into plain-language errors instead of raw curl output. call() { local out status body out=$(curl -sS --max-time 30 -w $'\n%{http_code}' \ -H "Authorization: Bearer $SECRET" "$@") \ || die "network error reaching $URL (offline, DNS, or the project was deleted)" status=${out##*$'\n'} body=${out%$'\n'*} case "$status" in 200) printf '%s\n' "$body" ;; 401) die "secret rejected (401). It has been rotated. Get the current secret from JY and update $CFG/secret. Do not retry with this one." ;; 410) die "the bus has expired (410) and is dead by design. Nothing to retry. Stop the loop." ;; 429) die "rate limited (429). Wait a minute before the next call." ;; *) die "unexpected HTTP $status: $body" ;; esac } cmd=${1:-} [ -n "$cmd" ] && shift case "$cmd" in health) call "$URL/health" ;; send) [ $# -ge 1 ] || die 'usage: bus.sh send "message text" (or: bus.sh send - to read stdin)' if [ "$1" = "-" ]; then TEXT=$(cat); else TEXT="$1"; fi [ -n "$TEXT" ] || die "refusing to send an empty message" PAYLOAD=$(printf '%s' "$TEXT" | python3 -c \ 'import json,sys; print(json.dumps({"to":sys.argv[1],"from":sys.argv[2],"body":sys.stdin.read()}))' \ "$THEM" "$ME") call -X POST "$URL/msg" -H "content-type: application/json" --data-binary "$PAYLOAD" ;; poll) RESP=$(call "$URL/msg?for=$ME&since=$(cursor)") BUS_RESP="$RESP" BUS_CURSOR_FILE="$CURSOR_FILE" python3 <<'PY' import json, os, datetime msgs = json.loads(os.environ["BUS_RESP"]) if not msgs: print("no new messages") else: for m in msgs: t = datetime.datetime.fromtimestamp(m["ts"] / 1000).strftime("%Y-%m-%d %H:%M:%S") print(f'--- msg {m["id"]} from {m["sender"]} at {t} ---') print(m["body"]) # Cursor advances only after the messages were printed. If this write is ever # lost the same messages replay on the next poll - the reader must tolerate # seeing a message twice, never miss one. with open(os.environ["BUS_CURSOR_FILE"], "w") as f: f.write(str(msgs[-1]["id"])) PY ;; wait) SECS=${1:-90} END=$(( $(date +%s) + SECS )) while :; do OUT=$("$0" poll) if [ "$OUT" != "no new messages" ]; then printf '%s\n' "$OUT" exit 0 fi if [ "$(date +%s)" -ge "$END" ]; then echo "no new messages after ${SECS}s" exit 0 fi sleep 15 done ;; *) die "usage: bus.sh health | send \"text\" | send - | poll | wait [seconds]" ;; esac