v0.6.1: fix TOOLS_JSON crash + slash robustness + backspace

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 <noreply@anthropic.com>
This commit is contained in:
Bryan Johnson 2026-05-27 10:45:49 -07:00
parent f58bcf711f
commit 99f0b03c8c
2 changed files with 102 additions and 22 deletions

View File

@ -1 +1 @@
0.6.0
0.6.1

122
larry.sh
View File

@ -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/*/<thread>.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/*/<thread>.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/<system>.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 `<thread> 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 ) 2>/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 <alias> <user@host[:port]>"; 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 <alias>"; 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 <alias>"; 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 <alias>"; 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 <alias>"; 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 <alias> <command>"; continue; fi
local alias="${rest%% *}" rcmd="${rest#"$alias"}"
rcmd="${rcmd# }"
if [ -z "$alias" ] || [ -z "$rcmd" ]; then
err "usage: /ssh <alias> <command>"; continue
fi
_run_ssh_helper exec "$alias" "$rcmd"
continue ;;
/redetect) detect_cloverleaf_env
system_prompt=$(build_system_prompt)