From 99f0b03c8c7e88ee3dccaabacc57dc9472c83924 Mon Sep 17 00:00:00 2001 From: Bryan Johnson Date: Wed, 27 May 2026 10:45:49 -0700 Subject: [PATCH] v0.6.1: fix TOOLS_JSON crash + slash robustness + backspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THREE bugs that Bryan hit in v0.6.0: (1) TOOLS_JSON: unbound variable crash on any unrecognised input. Root cause: the TOOLS_JSON assignment was a single-quoted string spanning ~40 lines, and apostrophes in tool descriptions (Anthropic's, 'codametrix', protocol's, etc.) closed the bash string prematurely. Bash then tried to execute fragments of the JSON as shell commands ("NAME: command not found" warnings) and TOOLS_JSON never got assigned. With set -u, the first reference to $TOOLS_JSON crashed the whole script. Fix: switch to a quoted-EOF heredoc — TOOLS_JSON=$(cat <<'TOOLS_END' ... TOOLS_END). Heredoc with single-quoted delimiter preserves content literally — apostrophes, backslashes, all of it. Verified: all 31 tool defs now parse as valid JSON (including the previously-broken nc_msgs). Also fixed the pre-existing \\" → \" escape error in nc_msgs. (2) Slash command brittleness: /ssh-add\ * pattern matched only when args were present; /ssh-add alone fell through to the catchall and reported "unknown command". Also failed silently with no usage message on the too-few-args path. Fix: rewrote all SSH slash patterns as /ssh-foo* (matches both with and without trailing args), with a _slash_args() helper that cleanly extracts the arg portion. Every handler now validates and prints "usage: ..." on missing args before continuing. New _run_ssh_helper() wrapper centralises the installed-check and swallows helper exit codes so they don't propagate into the main loop. (3) Backspace not working in MobaXterm/Cygwin terminals. Root cause: terminal sends ^? (DEL) for backspace but stty erase is often set to ^H (BS) in MobaXterm, so backspace passes through as a literal character. Fix: stty erase '^?' at REPL startup (harmless if already correct), AND switch read_user_input to use `read -e -r -p` which uses libreadline for line editing — backspace, arrow keys, history all work via readline now, bypassing the terminal's stty config entirely. Falls back to plain read on environments without readline support. Co-Authored-By: Claude Opus 4.7 --- VERSION | 2 +- larry.sh | 122 +++++++++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 102 insertions(+), 22 deletions(-) diff --git a/VERSION b/VERSION index a918a2a..ee6cdce 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.0 +0.6.1 diff --git a/larry.sh b/larry.sh index bc17402..5bc9b64 100755 --- a/larry.sh +++ b/larry.sh @@ -36,7 +36,7 @@ set -o pipefail # ───────────────────────────────────────────────────────────────────────────── # Config # ───────────────────────────────────────────────────────────────────────────── -LARRY_VERSION="0.6.0" +LARRY_VERSION="0.6.1" LARRY_HOME="${LARRY_HOME:-$HOME/.larry}" LARRY_BASE_URL="${LARRY_BASE_URL:-https://raw.githubusercontent.com/bojj27/cloverleaf-larry/main}" LARRY_UPDATE_URL="${LARRY_UPDATE_URL:-${LARRY_BASE_URL}/larry.sh}" @@ -943,7 +943,8 @@ execute_tool() { # ───────────────────────────────────────────────────────────────────────────── # Tool schema for the API # ───────────────────────────────────────────────────────────────────────────── -TOOLS_JSON='[ +TOOLS_JSON=$(cat <<'TOOLS_END' +[ {"name":"read_file","description":"Read a single regular file. Returns content with line numbers. Max 250KB; use grep_files for larger.","input_schema":{"type":"object","properties":{"path":{"type":"string","description":"Path to file (absolute or relative to cwd)."}},"required":["path"]}}, {"name":"list_dir","description":"List a directory (ls -la). Use to map a Cloverleaf site_root.","input_schema":{"type":"object","properties":{"path":{"type":"string","description":"Directory path. Defaults to current dir."}},"required":["path"]}}, {"name":"grep_files","description":"Recursive grep across files. Use for finding TCL procs, UPOC declarations, segment references, etc. Returns up to 300 matching lines with file:line:content.","input_schema":{"type":"object","properties":{"pattern":{"type":"string","description":"Regex pattern (grep -E style)."},"path":{"type":"string","description":"Starting directory."}},"required":["pattern","path"]}}, @@ -965,7 +966,7 @@ TOOLS_JSON='[ {"name":"nc_sources","description":"List every protocol that has a DATAXLATE DEST routing to the named thread. The inverse of nc_destinations. Use this to find what feeds a given thread.","input_schema":{"type":"object","properties":{"netconfig":{"type":"string"},"name":{"type":"string","description":"Target thread name."}},"required":["netconfig","name"]}}, {"name":"nc_tclproc_refs","description":"List every TCL proc name referenced from a protocol block (or from the whole NetConfig if name is omitted). Pulls from DATAFORMAT.PROC, PREPROCS.PROCS, POSTPROCS.PROCS, etc. Unique sorted.","input_schema":{"type":"object","properties":{"netconfig":{"type":"string"},"name":{"type":"string","description":"Optional. Scope to one protocol."}},"required":["netconfig"]}}, {"name":"hl7_field","description":"Extract a specific HL7 v2 field from a message. field_path = SEG[.FIELD[.COMPONENT[.SUBCOMPONENT]]]. Examples: PID.3 (MRN), PID.18 (account number), MSH.7 (timestamp), MSH.9.2 (event code, like A08), PID.5 (patient name with components). Multiple repetitions are returned one per line. Native v3, no v1/v2 dependency.","input_schema":{"type":"object","properties":{"message":{"type":"string","description":"Raw HL7 message text. Segments separated by \\r."},"field_path":{"type":"string","description":"Field path like PID.3 or MSH.9.2"}},"required":["message","field_path"]}}, - {"name":"nc_msgs","description":"Query Cloverleaf smat (SQLite!) databases for messages from a thread. Filters: time range, exact HL7 field match. Native v3 — reads smatdb directly with sqlite3 -ascii, no hcidbdump/dbExtract needed. Format text shows messages line-by-line with metadata; count returns just the count; json returns structured data.","input_schema":{"type":"object","properties":{"thread":{"type":"string","description":"Thread name. The .smatdb file under $HCISITEDIR/exec/processes/*/.smatdb is auto-located unless db is given."},"after":{"type":"string","description":"Time-after filter. Accepts \\"3 days ago\\", \\"2026-05-20 14:30:00\\", \\"2026-05-20\\", or a unix timestamp."},"before":{"type":"string","description":"Time-before filter, same formats as after."},"field":{"type":"string","description":"HL7 field path for exact-match filter, e.g. PID.18 or MSH.10."},"value":{"type":"string","description":"Value the field must equal. Use with field. Repeatable filters not supported via this single tool call — chain calls if you need multi-field AND."},"limit":{"type":"integer","description":"Max messages to return. Default 10."},"format":{"type":"string","enum":["text","json","count","raw"],"description":"text = human-readable with metadata; count = just the number; json = structured; raw = raw bytes separated by 0x1c."},"sitedir":{"type":"string","description":"Override $HCISITEDIR for thread-to-db location."},"db":{"type":"string","description":"Explicit .smatdb path; overrides auto-locate."}},"required":["thread"]}}, + {"name":"nc_msgs","description":"Query Cloverleaf smat (SQLite!) databases for messages from a thread. Filters: time range, exact HL7 field match. Native v3 — reads smatdb directly with sqlite3 -ascii, no hcidbdump/dbExtract needed. Format text shows messages line-by-line with metadata; count returns just the count; json returns structured data.","input_schema":{"type":"object","properties":{"thread":{"type":"string","description":"Thread name. The .smatdb file under $HCISITEDIR/exec/processes/*/.smatdb is auto-located unless db is given."},"after":{"type":"string","description":"Time-after filter. Accepts \"3 days ago\", \"2026-05-20 14:30:00\", \"2026-05-20\", or a unix timestamp."},"before":{"type":"string","description":"Time-before filter, same formats as after."},"field":{"type":"string","description":"HL7 field path for exact-match filter, e.g. PID.18 or MSH.10."},"value":{"type":"string","description":"Value the field must equal. Use with field. Repeatable filters not supported via this single tool call — chain calls if you need multi-field AND."},"limit":{"type":"integer","description":"Max messages to return. Default 10."},"format":{"type":"string","enum":["text","json","count","raw"],"description":"text = human-readable with metadata; count = just the number; json = structured; raw = raw bytes separated by 0x1c."},"sitedir":{"type":"string","description":"Override $HCISITEDIR for thread-to-db location."},"db":{"type":"string","description":"Explicit .smatdb path; overrides auto-locate."}},"required":["thread"]}}, {"name":"nc_document","description":"Generate a complete markdown knowledge entry for a Cloverleaf subsystem identified by a name pattern. Walks every NetConfig under $HCIROOT, gathers config + sources + destinations + xlates + tclprocs for every matching thread, composes a markdown doc with placeholder context sections (Vendor POC, Internal Owner, Status, Escalation, Open items, Notes). Returns the doc text and (if out is given) writes it to that path.","input_schema":{"type":"object","properties":{"name":{"type":"string","description":"Case-insensitive substring/regex to match protocol names. e.g. 'codametrix', 'epic_adt', '3M'."},"out":{"type":"string","description":"Optional output file path. Convention: $LARRY_HOME/knowledge/.md."},"hciroot":{"type":"string","description":"Override $HCIROOT for the NetConfig scan."},"title":{"type":"string","description":"Doc title. Default derived from name."},"status":{"type":"string","description":"System status fill-in (production/test/decommissioning/...)."},"poc_internal":{"type":"string","description":"Internal owner fill-in."},"poc_vendor":{"type":"string","description":"Vendor POC fill-in."},"escalation":{"type":"string","description":"Escalation path fill-in."},"open_items":{"type":"string","description":"Open items / known issues fill-in. Can be multi-line, will be inserted as-is."},"notes":{"type":"string","description":"Freeform notes fill-in."}},"required":["name"]}}, {"name":"nc_find","description":"Cross-site thread search. Native v3 replacement for v1 tbn/tbp/tbh/tbpr/where. Walks every NetConfig under $HCIROOT and returns matching threads with site, port, host, process, direction, file, line. Modes: name=partial name match (like tbn); port=exact port (like tbp); host=substring on host (like tbh); process=substring on PROCESSNAME (like tbpr); where=exact name match across all sites (like the v1 ` where`); xlate=threads referencing a specific .xlt; tclproc=threads referencing a specific TCL proc.","input_schema":{"type":"object","properties":{"mode":{"type":"string","enum":["name","port","host","process","where","xlate","tclproc"],"description":"Search mode."},"query":{"type":"string","description":"Query value: partial name, port number, host substring, process name, exact thread name, xlate filename, or tclproc name."},"format":{"type":"string","enum":["table","tsv","jsonl"],"description":"Output format. Default table."},"hciroot":{"type":"string","description":"Override $HCIROOT."}},"required":["mode","query"]}}, @@ -984,7 +985,9 @@ TOOLS_JSON='[ {"name":"hl7_diff","description":"HL7-aware diff between two message files (or multi-message dumps). Compares segment-by-segment, field-by-field, with component and subcomponent precision. Ignores configured fields (default MSH.7 timestamp) so timestamp-only diffs do not show up as noise. Use for regression testing between environments (e.g. test vs prod route-test outputs).","input_schema":{"type":"object","properties":{"left":{"type":"string","description":"Path to left HL7 file."},"right":{"type":"string","description":"Path to right HL7 file."},"ignore":{"type":"string","description":"Comma-separated list of fields to ignore (e.g. MSH.7,MSH.10,EVN.6). Default MSH.7."},"include":{"type":"string","description":"If set, ONLY these fields are compared (overrides ignore for that set)."},"format":{"type":"string","enum":["text","tsv","count"],"description":"text=human-readable diff, tsv=machine-parseable, count=just the difference count."}},"required":["left","right"]}}, {"name":"nc_regression","description":"End-to-end regression testing between two Cloverleaf environments. 6 phases: discover inbounds in scope, sample N messages per inbound from env-A smatdbs, run route_test on env-A, run route_test on env-B with same inputs, hl7_diff every paired output file, compile summary report. Phases 3/4 require the Cloverleaf route_test command; pass it via route_test_cmd with placeholders {THREAD} {INPUT} {OUTPUT_DIR} {HCIROOT} {HCISITE}. If route_test_cmd is empty, phases 3/4 are skipped and you can run them manually using the generated input files.","input_schema":{"type":"object","properties":{"scope":{"type":"string","description":"thread:NAME | threads:N1,N2 | site (needs site_a) | server (all sites)"},"count":{"type":"integer","description":"Messages to sample per inbound. Default 10."},"env_a":{"type":"string","description":"HCIROOT of env-A (the test/source env)."},"site_a":{"type":"string","description":"Site name on env-A. Required if scope=site."},"env_b":{"type":"string","description":"HCIROOT of env-B (the prod/target env)."},"site_b":{"type":"string","description":"Site name on env-B."},"out":{"type":"string","description":"Output root directory for inputs, outputs, diffs, and summary."},"route_test_cmd":{"type":"string","description":"Command template for invoking route_test. Use {THREAD} {INPUT} {OUTPUT_DIR} {HCIROOT} {HCISITE} as placeholders."},"ignore":{"type":"string","description":"hl7_diff ignore list. Default MSH.7."},"phase":{"type":"string","enum":["1","2","3","4","5","6","all"],"description":"Run a specific phase or all. Default all."},"dry_run":{"type":"integer","description":"1 = print what would happen, do not execute. Default 0."}},"required":["scope","env_a","env_b","out"]}} -]' +] +TOOLS_END +) # ───────────────────────────────────────────────────────────────────────────── # API call @@ -1158,11 +1161,52 @@ Multi-line input: start with '<<' on its own line, end with 'EOF' on its own lin EOF } +# _slash_args CMD INPUT +# Strip a leading "/cmd " (or just "/cmd") from INPUT and echo whatever follows. +# If INPUT is just "/cmd" alone, echoes empty. Robust across bash versions — +# doesn't rely on case-pattern escaped-space matching. +_slash_args() { + local cmd="$1" input="$2" + case "$input" in + "$cmd") printf '' ;; + "$cmd "*) printf '%s' "${input#"$cmd "}" ;; + "$cmd"*) printf '%s' "${input#"$cmd"}" ;; # no-space variants (rare) + *) printf '' ;; + esac +} + +# _run_ssh_helper SUBCMD [ARGS...] +# Invoke lib/ssh-helper.sh with arguments. Centralises the installed/missing +# check and shields the main REPL from sub-helper exit codes (so a failing +# ssh command doesn't propagate out and trip set -u elsewhere). +_run_ssh_helper() { + local helper="$LARRY_LIB_DIR/ssh-helper.sh" + if [ ! -x "$helper" ]; then + err "ssh-helper.sh not installed (expected at $helper)" + return 0 + fi + "$helper" "$@" || true +} + read_user_input() { # Returns user input via global LARRY_INPUT. # If first line is "<<", read until line "EOF" (heredoc-style). + # + # Uses readline editing (-e) so backspace, arrow keys, and history work + # correctly across terminals (MobaXterm/Cygwin in particular often has + # stty erase mismatches that swallow plain `read`'s backspace). We pass + # the prompt via -p so readline knows the visible width. LARRY_INPUT="" - local first; IFS= read -r first || return 1 + local first + if [ -t 0 ] && _readline_ok; then + local prompt; prompt=$(printf '%syou>%s ' "$C_GREEN" "$C_RESET") + # Clear the prompt the caller already printed, then re-emit via readline. + printf '\r\033[K' + IFS= read -e -r -p "$prompt" first || return 1 + [ -n "$first" ] && history -s "$first" + else + IFS= read -r first || return 1 + fi if [ "$first" = "<<" ]; then local line while IFS= read -r line; do @@ -1174,6 +1218,14 @@ read_user_input() { fi } +# _readline_ok — true if `read -e` is supported by this bash and stdin is a tty. +# Cygwin/MobaXterm bash usually supports it; some stripped-down environments +# (busybox, dash) don't. +_readline_ok() { + local _x + ( IFS= read -e -r -t 0 _x /dev/null +} + main_loop() { local system_prompt; system_prompt=$(build_system_prompt) @@ -1197,6 +1249,15 @@ main_loop() { printf '%s%s═══════════════════════════════════════════════════════════════%s\n' "$C_GREEN" "$C_BOLD" "$C_RESET" echo "" fi + # ── Terminal fixups ──────────────────────────────────────────────────────── + # Some terminals (notably MobaXterm/Cygwin and certain SSH setups) ship with + # stty erase set to ^H while the keyboard actually sends ^? (DEL) for + # backspace, so backspace gets passed through to read() as a literal char. + # Force erase=^? if we have a tty; harmless if already correct. + if [ -t 0 ] && command -v stty >/dev/null 2>&1; then + stty erase '^?' 2>/dev/null || true + fi + larry_say "${C_BOLD}Larry-Anywhere v$LARRY_VERSION${C_RESET} ready. Model: $LARRY_MODEL." larry_say "Type your message and press Enter. Use '<<' alone on a line to start multi-line (end with 'EOF'). /help for commands." echo "" @@ -1241,29 +1302,48 @@ main_loop() { || err "hl7-sanitize.sh not installed" continue ;; # ── SSH ControlMaster commands (password never visible to Larry-the-LLM) ── - /ssh-hosts|/ssh-list) [ -x "$LARRY_LIB_DIR/ssh-helper.sh" ] && "$LARRY_LIB_DIR/ssh-helper.sh" hosts \ - || err "ssh-helper.sh not installed" + # Patterns use /foo* (matches both "/foo" alone and "/foo args") for + # robustness across bash versions. Body strips the prefix and validates. + /ssh-hosts*|/ssh-list*) + _run_ssh_helper hosts continue ;; - /ssh-add\ *) local rest="${input#/ssh-add }"; local args=($rest) - if [ -x "$LARRY_LIB_DIR/ssh-helper.sh" ]; then "$LARRY_LIB_DIR/ssh-helper.sh" add "${args[@]}"; else err "ssh-helper.sh not installed"; fi + /ssh-add*) local rest; rest=$(_slash_args "/ssh-add" "$input") + if [ -z "$rest" ]; then + err "usage: /ssh-add "; continue + fi + # shellcheck disable=SC2086 + _run_ssh_helper add $rest continue ;; - /ssh-remove\ *|/ssh-rm\ *) local a="${input#/ssh-* }" - if [ -x "$LARRY_LIB_DIR/ssh-helper.sh" ]; then "$LARRY_LIB_DIR/ssh-helper.sh" remove "$a"; else err "ssh-helper.sh not installed"; fi + /ssh-remove*|/ssh-rm*) + local rest; rest=$(_slash_args "/ssh-remove" "$input") + [ -z "$rest" ] && rest=$(_slash_args "/ssh-rm" "$input") + if [ -z "$rest" ]; then err "usage: /ssh-remove "; continue; fi + _run_ssh_helper remove "$rest" continue ;; - /ssh-pass\ *) local a="${input#/ssh-pass }" - if [ -x "$LARRY_LIB_DIR/ssh-helper.sh" ]; then "$LARRY_LIB_DIR/ssh-helper.sh" pass "$a"; else err "ssh-helper.sh not installed"; fi + /ssh-pass*) local rest; rest=$(_slash_args "/ssh-pass" "$input") + if [ -z "$rest" ]; then err "usage: /ssh-pass "; continue; fi + _run_ssh_helper pass "$rest" continue ;; - /ssh-setup\ *) local a="${input#/ssh-setup }" - if [ -x "$LARRY_LIB_DIR/ssh-helper.sh" ]; then "$LARRY_LIB_DIR/ssh-helper.sh" setup "$a"; else err "ssh-helper.sh not installed"; fi + /ssh-setup*) local rest; rest=$(_slash_args "/ssh-setup" "$input") + if [ -z "$rest" ]; then err "usage: /ssh-setup "; continue; fi + _run_ssh_helper setup "$rest" continue ;; - /ssh-close\ *) local a="${input#/ssh-close }" - if [ -x "$LARRY_LIB_DIR/ssh-helper.sh" ]; then "$LARRY_LIB_DIR/ssh-helper.sh" close "$a"; else err "ssh-helper.sh not installed"; fi + /ssh-close*) local rest; rest=$(_slash_args "/ssh-close" "$input") + if [ -z "$rest" ]; then err "usage: /ssh-close "; continue; fi + _run_ssh_helper close "$rest" continue ;; - /ssh-status) [ -x "$LARRY_LIB_DIR/ssh-helper.sh" ] && "$LARRY_LIB_DIR/ssh-helper.sh" status \ - || err "ssh-helper.sh not installed" + /ssh-status*) + local rest; rest=$(_slash_args "/ssh-status" "$input") + if [ -n "$rest" ]; then _run_ssh_helper status "$rest"; else _run_ssh_helper status; fi continue ;; - /ssh\ *) local rest="${input#/ssh }"; local alias="${rest%% *}"; local rcmd="${rest#"$alias" }" - if [ -x "$LARRY_LIB_DIR/ssh-helper.sh" ]; then "$LARRY_LIB_DIR/ssh-helper.sh" exec "$alias" "$rcmd"; else err "ssh-helper.sh not installed"; fi + /ssh*) local rest; rest=$(_slash_args "/ssh" "$input") + if [ -z "$rest" ]; then err "usage: /ssh "; continue; fi + local alias="${rest%% *}" rcmd="${rest#"$alias"}" + rcmd="${rcmd# }" + if [ -z "$alias" ] || [ -z "$rcmd" ]; then + err "usage: /ssh "; continue + fi + _run_ssh_helper exec "$alias" "$rcmd" continue ;; /redetect) detect_cloverleaf_env system_prompt=$(build_system_prompt)