diff --git a/VERSION b/VERSION index 267577d..2b7c5ae 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.1 +0.4.2 diff --git a/install-larry.sh b/install-larry.sh index 54932e2..ba62a75 100755 --- a/install-larry.sh +++ b/install-larry.sh @@ -99,6 +99,13 @@ fetch lib/each-site.sh "$LARRY_HOME/lib/each-site.sh" fetch lib/len2nl.sh "$LARRY_HOME/lib/len2nl.sh" fetch lib/csv-to-table.sh "$LARRY_HOME/lib/csv-to-table.sh" fetch lib/table-to-csv.sh "$LARRY_HOME/lib/table-to-csv.sh" +fetch lib/nc-engine.sh "$LARRY_HOME/lib/nc-engine.sh" +fetch lib/nc-status.sh "$LARRY_HOME/lib/nc-status.sh" +fetch lib/nc-table.sh "$LARRY_HOME/lib/nc-table.sh" +fetch lib/nc-xlate.sh "$LARRY_HOME/lib/nc-xlate.sh" +fetch lib/nc-smat-diff.sh "$LARRY_HOME/lib/nc-smat-diff.sh" +fetch lib/nc-create-thread.sh "$LARRY_HOME/lib/nc-create-thread.sh" +fetch lib/nc-tclgen.sh "$LARRY_HOME/lib/nc-tclgen.sh" fetch lib/nc-parse.sh "$LARRY_HOME/lib/nc-parse.sh" fetch lib/nc-inbound.sh "$LARRY_HOME/lib/nc-inbound.sh" fetch lib/nc-make-jump.sh "$LARRY_HOME/lib/nc-make-jump.sh" diff --git a/larry.sh b/larry.sh index 946d894..28b38e0 100755 --- a/larry.sh +++ b/larry.sh @@ -32,7 +32,7 @@ set -o pipefail # ───────────────────────────────────────────────────────────────────────────── # Config # ───────────────────────────────────────────────────────────────────────────── -LARRY_VERSION="0.4.1" +LARRY_VERSION="0.4.2" LARRY_HOME="${LARRY_HOME:-$HOME/.larry}" LARRY_UPDATE_URL="${LARRY_UPDATE_URL:-https://raw.githubusercontent.com/bojj27/cloverleaf-larry/main/larry.sh}" LARRY_AGENTS_URL="${LARRY_AGENTS_URL:-https://raw.githubusercontent.com/bojj27/cloverleaf-larry/main/agents}" diff --git a/lib/nc-create-thread.sh b/lib/nc-create-thread.sh new file mode 100755 index 0000000..c31c6e8 --- /dev/null +++ b/lib/nc-create-thread.sh @@ -0,0 +1,199 @@ +#!/usr/bin/env bash +# nc-create-thread.sh — high-level: create a new thread in a NetConfig and +# (optionally) wire a route from another thread to it. +# +# Combines nc-make-jump's emit helpers + nc-insert-protocol for a single +# user-facing operation. Goes through the journal. +# +# Usage: +# nc-create-thread.sh --name NEW_THREAD --site SITE --netconfig PATH +# --type tcpip|file +# --direction inbound|outbound +# --port PORT [--host HOST] +# [--process PROC] +# [--encoding ASCII] +# [--connect-from EXISTING_THREAD] # add a route on EXISTING → NEW +# [--route-type raw|xlate|generate] # default raw +# [--xlate XLATENAME] # if route-type=xlate +# [--trxid REGEX] # default .* +# +# Example: create a new outbound thread `to_metrics` in process `metrics` on +# 10.0.0.50:51999, raw-route from existing `IB_ADT_muxS` to it. +set -o pipefail + +NC_SELF="$0" +LIB_DIR="$(cd "$(dirname "$NC_SELF")" && pwd)" +NCP="$LIB_DIR/nc-parse.sh" +NCI="$LIB_DIR/nc-insert-protocol.sh" + +die() { printf 'nc-create-thread: %s\n' "$*" >&2; exit 1; } + +NAME=""; SITE="${HCISITE:-}"; NC="" +TYPE="tcpip"; DIRECTION="outbound"; PORT=""; HOST="" +PROCESS=""; ENCODING="ASCII" +CONNECT_FROM=""; ROUTE_TYPE="raw"; XLATE=""; TRXID=".*" + +while [ $# -gt 0 ]; do + case "$1" in + --name) shift; NAME="$1" ;; + --site) shift; SITE="$1" ;; + --netconfig) shift; NC="$1" ;; + --type) shift; TYPE="$1" ;; + --direction) shift; DIRECTION="$1" ;; + --port) shift; PORT="$1" ;; + --host) shift; HOST="$1" ;; + --process) shift; PROCESS="$1" ;; + --encoding) shift; ENCODING="$1" ;; + --connect-from) shift; CONNECT_FROM="$1" ;; + --route-type) shift; ROUTE_TYPE="$1" ;; + --xlate) shift; XLATE="$1" ;; + --trxid) shift; TRXID="$1" ;; + -h|--help) sed -n '2,20p' "$NC_SELF"; exit 0 ;; + *) die "unknown arg: $1" ;; + esac + shift +done + +[ -n "$NAME" ] || die "missing --name" +[ -n "$PORT" ] || die "missing --port" +[ -n "$NC" ] || NC="${HCIROOT:-}/${SITE}/NetConfig" +[ -f "$NC" ] || die "no such netconfig: $NC" +[ -z "$PROCESS" ] && PROCESS="$SITE" + +is_server=0 +outonly=1 +obib=0 +case "$DIRECTION" in + inbound) is_server=1; outonly=0; obib=1 ;; + outbound) is_server=0; outonly=1; obib=0 ;; + *) die "bad --direction" ;; +esac + +# Build the protocol block +build_block() { + cat < "$BLOCK_FILE" +printf '\n=== Generated protocol block for %s ===\n\n' "$NAME" +cat "$BLOCK_FILE" +printf '\n' + +# Insert it +"$NCI" insert "$NC" "$BLOCK_FILE" + +# If --connect-from, also splice a route into the source thread's DATAXLATE +if [ -n "$CONNECT_FROM" ]; then + ROUTE_FILE=$(mktemp); build_route_entry > "$ROUTE_FILE" + printf '\n=== Route to splice into %s DATAXLATE ===\n\n' "$CONNECT_FROM" + cat "$ROUTE_FILE" + printf '\n' + "$NCI" add-route "$NC" "$CONNECT_FROM" "$ROUTE_FILE" + rm -f "$ROUTE_FILE" +fi +rm -f "$BLOCK_FILE" diff --git a/lib/nc-engine.sh b/lib/nc-engine.sh new file mode 100755 index 0000000..109ba36 --- /dev/null +++ b/lib/nc-engine.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +# nc-engine.sh — Cloverleaf engine process control. Native v3 wrapper +# around the shipped Cloverleaf binaries — modelled on v1 `bounce`, +# `bounce_processes`, `pstop`, `start`, etc. +# +# Every action goes through the journal so it's reversible. Bounces are +# journaled as paired stop+start records; the rollback executes them in +# reverse to restore prior state (best-effort — engine state can drift). +# +# Subcommands: +# stop [more...] stop one or more processes/threads +# start [more...] start one or more +# bounce [more...] stop then start (atomic-ish) +# restart alias of bounce +# status quick site status via tstat (if available) +# resend-ob resend a file outbound (post-xlate) +# resend-ib resend a file inbound (pre-xlate) +# route-test run Cloverleaf route_test for a thread +# testxlate test an xlate against an xlt file +# tpstest run a TPS test +# +# Options for stop/start/bounce: +# --site SITE override $HCISITE for this call +# --confirm yes skip Y/N prompt (still journaled) +# --dry-run show the binary command but do not execute +# +# Cloverleaf binaries used (auto-discovered under $HCIROOT/bin/): +# hcienginestop hcienginerun hcienginerestart hcienginestat tstat +# hciengineroutetest hciengineenginesend ... +set -o pipefail + +NC_SELF="$0" +LIB_DIR="$(cd "$(dirname "$NC_SELF")" && pwd)" +JOURNAL="$LIB_DIR/journal.sh" + +die() { printf 'nc-engine: %s\n' "$*" >&2; exit 1; } +warn() { printf 'nc-engine: %s\n' "$*" >&2; } + +# Source journal so journaled actions can call journal_write +[ -f "$JOURNAL" ] && . "$JOURNAL" || warn "journal.sh not available — actions will not be reversible" + +resolve_binary() { + local name="$1" + if command -v "$name" >/dev/null 2>&1; then command -v "$name"; return; fi + for d in "${HCIROOT:-}/bin" "${HCIROOT:-}/server/bin"; do + [ -x "$d/$name" ] && { echo "$d/$name"; return; } + done + return 1 +} + +journal_action() { + # Record an engine action in the journal as a synthetic "command" entry. + # We don't snapshot files (these are runtime ops, not file edits) but we + # write a manifest-style entry so larry-rollback.sh --list shows them. + local action="$1" target="$2" detail="${3:-}" + local sessdir="$LARRY_HOME/journal/${LARRY_SESSION_ID:-engine-$(date +%Y-%m-%d-%H%M%S)-$$}" + mkdir -p "$sessdir" 2>/dev/null + local idx; idx=$(printf '%03d' $(($(find "$sessdir" -name '[0-9]*.engine' 2>/dev/null | wc -l) + 1))) + local entry="$sessdir/${idx}_${action}_${target//\//_}.engine" + { + printf 'action: %s\ntarget: %s\nwhen: %s\nhost: %s\nhciroot: %s\nhcisite: %s\ndetail: %s\n' \ + "$action" "$target" "$(date -Iseconds 2>/dev/null || date)" \ + "$(hostname 2>/dev/null || echo unknown)" "${HCIROOT:-?}" "${HCISITE:-?}" "$detail" + } > "$entry" + # Also append to a flat engine log for quick listing + local elog="$LARRY_HOME/journal/engine-actions.tsv" + [ -f "$elog" ] || printf 'when\tsession\taction\ttarget\thciroot\thcisite\n' > "$elog" + printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$(date -Iseconds 2>/dev/null || date)" \ + "${LARRY_SESSION_ID:-?}" "$action" "$target" "${HCIROOT:-?}" "${HCISITE:-?}" >> "$elog" +} + +run_action() { + local action="$1" target="$2"; shift 2 + local site="${HCISITE:-}" + local confirm="" + local dry=0 + while [ $# -gt 0 ]; do + case "$1" in + --site) shift; site="$1" ;; + --confirm) shift; confirm="$1" ;; + --dry-run) dry=1 ;; + esac + shift + done + + local binary cmd label + case "$action" in + stop) binary=$(resolve_binary hcienginestop) || die "hcienginestop not found"; cmd="$binary -p $target"; label="STOP" ;; + start) binary=$(resolve_binary hcienginerun) || die "hcienginerun not found"; cmd="$binary -p $target"; label="START" ;; + bounce|restart) + binary=$(resolve_binary hcienginerestart) \ + && cmd="$binary -p $target" && label="BOUNCE" \ + || { + # Fallback to stop + start + local sbin; sbin=$(resolve_binary hcienginestop) || die "hcienginestop+hcienginerestart both missing" + local rbin; rbin=$(resolve_binary hcienginerun) || die "hcienginerun missing" + cmd="$sbin -p $target && $rbin -p $target" + label="BOUNCE" + } ;; + *) die "unknown action: $action" ;; + esac + + printf '\n%s%s%s thread/process=%s site=%s\n' "${C_YELLOW:-}" "$label" "${C_RESET:-}" "$target" "${site:-?}" + printf ' $ %s\n' "$cmd" + + if [ "$dry" = "1" ]; then + printf ' [dry-run] not executed\n' + return 0 + fi + + if [ "$confirm" != "yes" ]; then + printf ' proceed? [y/N]: ' + read -r ans /dev/null || ans="" + [[ "$ans" =~ ^[Yy]$ ]] || { echo " DENIED by user"; return 1; } + fi + + journal_action "$action" "$target" "$cmd" + HCISITE="$site" eval "$cmd" + local rc=$? + if [ "$rc" -eq 0 ]; then echo " ✓ ok"; else warn " exit $rc"; fi + return $rc +} + +cmd_status() { + local site="${HCISITE:-}" + local binary + binary=$(resolve_binary hcienginestat) || binary=$(resolve_binary tstat) || die "no engine-status binary on PATH (looked for hcienginestat, tstat)" + HCISITE="$site" "$binary" "$@" +} + +cmd_resend() { + local kind="$1" thread="$2" file="$3"; shift 3 + [ -n "$thread" ] && [ -f "$file" ] || die "usage: resend-{ib,ob} " + local cmd + case "$kind" in + ob) cmd="$thread resend_ob $file" ;; + ib) cmd="$thread resend_ib $file" ;; + *) die "bad resend kind: $kind" ;; + esac + printf '\nRESEND-%s thread=%s file=%s\n $ %s\n proceed? [y/N]: ' "${kind^^}" "$thread" "$file" "$cmd" + read -r ans /dev/null || ans="" + [[ "$ans" =~ ^[Yy]$ ]] || { echo " DENIED"; return 1; } + journal_action "resend-$kind" "$thread" "file=$file" + eval "$cmd" +} + +cmd_route_test() { + local thread="$1" file="$2" + [ -n "$thread" ] && [ -f "$file" ] || die "usage: route-test " + local cmd="$thread route_test $file" + printf '\nROUTE-TEST thread=%s input=%s\n $ %s\n proceed? [y/N]: ' "$thread" "$file" "$cmd" + read -r ans /dev/null || ans="" + [[ "$ans" =~ ^[Yy]$ ]] || { echo " DENIED"; return 1; } + journal_action "route-test" "$thread" "file=$file" + eval "$cmd" +} + +cmd_testxlate() { + local xlate="$1" xltfile="$2" + [ -n "$xlate" ] && [ -f "$xltfile" ] || die "usage: testxlate " + local cmd="testxlate $xlate $xltfile" + printf '\nTESTXLATE xlate=%s file=%s\n $ %s\n proceed? [y/N]: ' "$xlate" "$xltfile" "$cmd" + read -r ans /dev/null || ans="" + [[ "$ans" =~ ^[Yy]$ ]] || { echo " DENIED"; return 1; } + journal_action "testxlate" "$xlate" "file=$xltfile" + eval "$cmd" +} + +cmd_tpstest() { + local msgfile="$1"; shift + [ -f "$msgfile" ] || die "usage: tpstest " + local procs; procs="$*" + local cmd="tpstest $msgfile $procs" + printf '\nTPSTEST msgfile=%s procs=%s\n $ %s\n proceed? [y/N]: ' "$msgfile" "$procs" "$cmd" + read -r ans /dev/null || ans="" + [[ "$ans" =~ ^[Yy]$ ]] || { echo " DENIED"; return 1; } + journal_action "tpstest" "$msgfile" "procs=$procs" + eval "$cmd" +} + +SUB="${1:-help}" +case "$SUB" in + stop|start|bounce|restart) + shift + [ $# -ge 1 ] || die "usage: $SUB [more...] [--site SITE] [--confirm yes] [--dry-run]" + # Separate targets from flags + targets=(); flags=() + while [ $# -gt 0 ]; do + case "$1" in --*) flags+=("$1" "${2:-}"); shift 2 ;; *) targets+=("$1"); shift ;; esac + done + for t in "${targets[@]}"; do run_action "$SUB" "$t" "${flags[@]}"; done + ;; + status) shift; cmd_status "$@" ;; + resend-ob) shift; cmd_resend ob "$@" ;; + resend-ib) shift; cmd_resend ib "$@" ;; + route-test) shift; cmd_route_test "$@" ;; + testxlate) shift; cmd_testxlate "$@" ;; + tpstest) shift; cmd_tpstest "$@" ;; + help|-h|--help) sed -n '2,30p' "$NC_SELF" ;; + *) die "unknown subcommand: $SUB" ;; +esac diff --git a/lib/nc-smat-diff.sh b/lib/nc-smat-diff.sh new file mode 100755 index 0000000..30b3abc --- /dev/null +++ b/lib/nc-smat-diff.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# nc-smat-diff.sh — diff smat (message archive) content across two environments. +# Different from nc-regression which runs route_test; this just samples actual +# stored messages from each env's smatdb and compares them. +# +# Pairing strategy: +# By default, sample N most-recent messages from each side, then pair by +# MSH.10 (message control ID) — the standard HL7 unique identifier. +# Unmatched IDs are reported as A-only / B-only. +# +# Usage: +# nc-smat-diff.sh --env-a HCIROOT_A --env-b HCIROOT_B [opts] +# +# Options: +# --site-a SITE site on env-A (default $HCISITE) +# --site-b SITE site on env-B (default same as --site-a) +# --limit N messages to sample per side (default 50) +# --ignore FIELDS hl7-diff --ignore list (default "MSH.7") +# --out DIR output directory for per-msg diffs + summary +# --pair-on FIELD HL7 field to pair messages on (default MSH.10) +# --include-history include SmatHistory archives on both sides +# --after EXPR only messages after this time (e.g. "3 days ago") +set -o pipefail + +NC_SELF="$0" +LIB_DIR="$(cd "$(dirname "$NC_SELF")" && pwd)" +NCM="$LIB_DIR/nc-msgs.sh" +HL7F="$LIB_DIR/hl7-field.sh" +HL7DIFF="$LIB_DIR/hl7-diff.sh" + +die() { printf 'nc-smat-diff: %s\n' "$*" >&2; exit 1; } + +THREAD="" +ENV_A="" +ENV_B="" +SITE_A="${HCISITE:-}" +SITE_B="" +LIMIT=50 +IGNORE="MSH.7" +OUT="" +PAIR_ON="MSH.10" +INC_HIST=0 +AFTER="" + +while [ $# -gt 0 ]; do + case "$1" in + --env-a) shift; ENV_A="$1" ;; + --env-b) shift; ENV_B="$1" ;; + --site-a) shift; SITE_A="$1" ;; + --site-b) shift; SITE_B="$1" ;; + --limit) shift; LIMIT="$1" ;; + --ignore) shift; IGNORE="$1" ;; + --out) shift; OUT="$1" ;; + --pair-on) shift; PAIR_ON="$1" ;; + --include-history) INC_HIST=1 ;; + --after) shift; AFTER="$1" ;; + -h|--help) sed -n '2,22p' "$NC_SELF"; exit 0 ;; + -*) die "unknown flag: $1" ;; + *) [ -z "$THREAD" ] && THREAD="$1" || die "extra arg: $1" ;; + esac + shift +done + +[ -n "$THREAD" ] || die "missing thread name" +[ -n "$ENV_A" ] && [ -n "$ENV_B" ] || die "--env-a and --env-b required" +[ -n "$SITE_A" ] || die "--site-a required (or set HCISITE)" +[ -z "$SITE_B" ] && SITE_B="$SITE_A" +[ -z "$OUT" ] && OUT=$(mktemp -d) +mkdir -p "$OUT/a" "$OUT/b" "$OUT/diff" 2>/dev/null + +dump_side() { + local hciroot="$1" site="$2" target_dir="$3" + local nc_args=("$THREAD" --limit "$LIMIT" --format raw) + [ "$INC_HIST" = "1" ] && nc_args+=(--include-history) + [ -n "$AFTER" ] && nc_args+=(--after "$AFTER") + HCISITEDIR="$hciroot/$site" "$NCM" "${nc_args[@]}" > "$target_dir/all.raw" 2>"$target_dir/err" + + # Split into individual messages on 0x1c, and index by pair-on field + awk -v RS=$'\x1c' -v dir="$target_dir" ' + NF > 0 || $0 != "" { + n++ + fpath = dir "/msg_" sprintf("%05d", n) ".hl7" + printf "%s", $0 > fpath + close(fpath) + } + ' "$target_dir/all.raw" + + # Build pair-on-field → file index + : > "$target_dir/index.tsv" + for f in "$target_dir"/msg_*.hl7; do + [ -f "$f" ] || continue + local key; key=$("$HL7F" "$PAIR_ON" "$f" 2>/dev/null | head -1) + [ -z "$key" ] && key="(no-$PAIR_ON)" + printf '%s\t%s\n' "$key" "$f" >> "$target_dir/index.tsv" + done +} + +printf 'nc-smat-diff:\n thread: %s\n A: %s/%s\n B: %s/%s\n limit: %d ignore: %s pair-on: %s\n out: %s\n\n' \ + "$THREAD" "$ENV_A" "$SITE_A" "$ENV_B" "$SITE_B" "$LIMIT" "$IGNORE" "$PAIR_ON" "$OUT" >&2 + +dump_side "$ENV_A" "$SITE_A" "$OUT/a" +dump_side "$ENV_B" "$SITE_B" "$OUT/b" + +A_COUNT=$(wc -l < "$OUT/a/index.tsv" | tr -d ' ') +B_COUNT=$(wc -l < "$OUT/b/index.tsv" | tr -d ' ') +printf 'A: %d msgs B: %d msgs\n\n' "$A_COUNT" "$B_COUNT" >&2 + +# Pair messages by key +sort -k1 "$OUT/a/index.tsv" > "$OUT/a/sorted.tsv" +sort -k1 "$OUT/b/index.tsv" > "$OUT/b/sorted.tsv" +A_KEYS=$(awk -F'\t' '{print $1}' "$OUT/a/sorted.tsv" | sort -u) +B_KEYS=$(awk -F'\t' '{print $1}' "$OUT/b/sorted.tsv" | sort -u) + +SUMMARY="$OUT/_summary.md" +{ + printf '# smat diff: thread=%s\n\n' "$THREAD" + printf '- A: `%s/%s` (%d messages sampled)\n' "$ENV_A" "$SITE_A" "$A_COUNT" + printf '- B: `%s/%s` (%d messages sampled)\n' "$ENV_B" "$SITE_B" "$B_COUNT" + printf '- pair-on: `%s`\n' "$PAIR_ON" + printf '- ignore: `%s`\n\n' "$IGNORE" + printf '## Per-pair diffs\n\n' + printf '| %s | diffs | report |\n|---|---|---|\n' "$PAIR_ON" +} > "$SUMMARY" + +DIFFS_TOTAL=0 +A_ONLY=0 +B_ONLY=0 +PAIRED=0 +while IFS= read -r key; do + [ -z "$key" ] && continue + a_files=$(grep -F "${key} " "$OUT/a/sorted.tsv" | awk -F'\t' '{print $2}' | head -1) + b_files=$(grep -F "${key} " "$OUT/b/sorted.tsv" | awk -F'\t' '{print $2}' | head -1) + if [ -z "$a_files" ] && [ -n "$b_files" ]; then + B_ONLY=$((B_ONLY+1)) + echo "| \`$key\` | (only on B) | — |" >> "$SUMMARY" + elif [ -z "$b_files" ] && [ -n "$a_files" ]; then + A_ONLY=$((A_ONLY+1)) + echo "| \`$key\` | (only on A) | — |" >> "$SUMMARY" + else + PAIRED=$((PAIRED+1)) + report="$OUT/diff/${key//\//_}.md" + cnt=$("$HL7DIFF" --ignore "$IGNORE" --format count "$a_files" "$b_files" 2>/dev/null || echo "?") + { + printf '# msg %s\n\nA: `%s`\nB: `%s`\n\n' "$key" "$a_files" "$b_files" + "$HL7DIFF" --ignore "$IGNORE" "$a_files" "$b_files" 2>/dev/null + } > "$report" + echo "| \`$key\` | $cnt | [report](./diff/$(basename "$report")) |" >> "$SUMMARY" + DIFFS_TOTAL=$((DIFFS_TOTAL + ${cnt:-0})) + fi +done < <(printf '%s\n%s\n' "$A_KEYS" "$B_KEYS" | sort -u) + +{ + printf '\n## Summary\n\n' + printf '- paired (A and B): %d\n' "$PAIRED" + printf '- A-only: %d\n' "$A_ONLY" + printf '- B-only: %d\n' "$B_ONLY" + printf '- total field differences (post-ignore): %d\n' "$DIFFS_TOTAL" +} >> "$SUMMARY" + +printf 'done. Summary: %s\n' "$SUMMARY" >&2 +echo "$SUMMARY" diff --git a/lib/nc-status.sh b/lib/nc-status.sh new file mode 100755 index 0000000..6113bf1 --- /dev/null +++ b/lib/nc-status.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# nc-status.sh — Cloverleaf engine runtime status. Native v3 wrapper around +# the shipped status/tstat binaries — modelled on v1 `status`, `tstats`, +# `tstat`, `thread_status`, `procstatus`, `dstatus`, `connstatus`, `not_up`. +# +# Subcommands: +# sites site-level status (daemon + processes + threads) +# threads thread-level status (port, state, host) +# not-up only threads not in 'up' state +# connections raw connection state (from connstatus binary if available) +# queued threads with messages queued +# raw pass-through to the underlying tstat binary +# +# Flags (where applicable): +# --site SITE single site; default = all sites under $HCIROOT +# --filter REGEX match on thread name +# --format text|tsv default text +# +# This is V1-modeled but native-bash. The exact binary names depend on the +# Cloverleaf version; we auto-discover under $HCIROOT/bin (and server/bin). +set -o pipefail + +NC_SELF="$0" + +die() { printf 'nc-status: %s\n' "$*" >&2; exit 1; } +warn() { printf 'nc-status: %s\n' "$*" >&2; } + +resolve_binary() { + local name="$1" + if command -v "$name" >/dev/null 2>&1; then command -v "$name"; return; fi + for d in "${HCIROOT:-}/bin" "${HCIROOT:-}/server/bin"; do + [ -x "$d/$name" ] && { echo "$d/$name"; return; } + done + return 1 +} + +list_sites() { + local root="${HCIROOT:-}" + [ -d "$root" ] || die "no \$HCIROOT or it doesn't exist" + find "$root" -mindepth 1 -maxdepth 2 -name NetConfig -type f 2>/dev/null \ + | xargs -I{} dirname {} 2>/dev/null \ + | xargs -I{} basename {} 2>/dev/null \ + | sort -u +} + +cmd_sites() { + local target_site="" + local format="text" + while [ $# -gt 0 ]; do + case "$1" in + --site) shift; target_site="$1" ;; + --format) shift; format="$1" ;; + *) die "unknown flag: $1" ;; + esac + shift + done + local hcienginestat; hcienginestat=$(resolve_binary hcienginestat 2>/dev/null || true) + local sites_to_check + if [ -n "$target_site" ]; then sites_to_check="$target_site"; else sites_to_check=$(list_sites); fi + [ "$format" = "tsv" ] && printf 'site\tstate\tdetail\n' + while IFS= read -r s; do + [ -z "$s" ] && continue + local detail="" state="?" + if [ -n "$hcienginestat" ]; then + detail=$(HCISITE="$s" "$hcienginestat" 2>&1 || true) + if echo "$detail" | grep -qiE 'running|active|up'; then state="up" + elif echo "$detail" | grep -qiE 'stopped|down'; then state="down" + else state="unknown"; fi + else + # Fallback: check for lock file / pid file as a poor-man's check + if [ -e "${HCIROOT:-}/$s/lock" ] || [ -e "${HCIROOT:-}/$s/exec/processes" ]; then + state="config-present" + fi + fi + if [ "$format" = "tsv" ]; then + printf '%s\t%s\t%s\n' "$s" "$state" "$(echo "$detail" | head -1)" + else + printf '== %-20s [%s] ==\n%s\n' "$s" "$state" "$(echo "$detail" | head -10)" + fi + done <<< "$sites_to_check" +} + +cmd_threads() { + local target_site="${HCISITE:-}" + local filter="" + local format="text" + while [ $# -gt 0 ]; do + case "$1" in + --site) shift; target_site="$1" ;; + --filter) shift; filter="$1" ;; + --format) shift; format="$1" ;; + *) die "unknown flag: $1" ;; + esac + shift + done + local tstat; tstat=$(resolve_binary tstat) || die "tstat binary not found under \$HCIROOT/bin or PATH" + local args=() + [ -n "$target_site" ] && args+=("-s" "$target_site") + local output; output=$(HCISITE="$target_site" "$tstat" "${args[@]}" 2>&1 || true) + if [ -n "$filter" ]; then + output=$(printf '%s' "$output" | grep -E -- "$filter") + fi + if [ "$format" = "tsv" ]; then + # Best-effort: parse the tstat output into TSV + printf '%s\n' "$output" | awk 'NR>1 && NF>0 { gsub(/ +/, "\t"); print }' + else + printf '%s\n' "$output" + fi +} + +cmd_not_up() { + local site="${HCISITE:-}" + local filter="" + while [ $# -gt 0 ]; do + case "$1" in + --site) shift; site="$1" ;; + --filter) shift; filter="$1" ;; + *) die "unknown flag: $1" ;; + esac + shift + done + cmd_threads --site "$site" ${filter:+--filter "$filter"} \ + | awk 'NR==1 || tolower($0) !~ /\/' +} + +cmd_connections() { + local connstatus; connstatus=$(resolve_binary connstatus 2>/dev/null) || die "connstatus binary not found" + "$connstatus" "$@" +} + +cmd_queued() { + local site="${HCISITE:-}" + cmd_threads --site "$site" "$@" \ + | awk 'NR==1 || tolower($0) ~ /queued|obq|ibq/' +} + +cmd_raw() { + local tstat; tstat=$(resolve_binary tstat) || die "tstat binary not found" + "$tstat" "$@" +} + +SUB="${1:-sites}" +case "$SUB" in + sites) shift; cmd_sites "$@" ;; + threads) shift; cmd_threads "$@" ;; + not-up) shift; cmd_not_up "$@" ;; + connections) shift; cmd_connections "$@" ;; + queued) shift; cmd_queued "$@" ;; + raw) shift; cmd_raw "$@" ;; + help|-h|--help) sed -n '2,25p' "$NC_SELF" ;; + *) die "unknown subcommand: $SUB" ;; +esac diff --git a/lib/nc-table.sh b/lib/nc-table.sh new file mode 100755 index 0000000..ca0b48b --- /dev/null +++ b/lib/nc-table.sh @@ -0,0 +1,223 @@ +#!/usr/bin/env bash +# nc-table.sh — read and modify Cloverleaf lookup tables (.tbl files). +# Every modification goes through the journal (snapshot + diff + atomic write). +# +# Subcommands: +# list [--site SITE] list .tbl files +# show [--site SITE] dump the file +# pairs [--site SITE] [--format csv|tsv] input→output pairs only +# lookup find output for input +# reverse-lookup find input(s) for output +# add [--site SITE] add or update a row (journaled) +# delete [--site SITE] delete a row by input (journaled) +# create --from-csv FILE [opts] create a new table from CSV +# replace --from-csv FILE [opts] replace contents (journaled) +# +# Find tables at: $HCISITEDIR/tables/.tbl (or $HCIROOT/Tables/ shared) +set -o pipefail + +NC_SELF="$0" +LIB_DIR="$(cd "$(dirname "$NC_SELF")" && pwd)" +JOURNAL="$LIB_DIR/journal.sh" +C2T="$LIB_DIR/csv-to-table.sh" +T2C="$LIB_DIR/table-to-csv.sh" + +die() { printf 'nc-table: %s\n' "$*" >&2; exit 1; } + +[ -f "$JOURNAL" ] && . "$JOURNAL" + +locate_table() { + local name="$1" site="${2:-${HCISITE:-}}" + # Strip .tbl if user gave it + name="${name%.tbl}" + for d in \ + "${HCIROOT:-}/$site/tables" \ + "${HCIROOT:-}/$site/Tables" \ + "${HCIROOT:-}/Tables"; do + [ -f "$d/${name}.tbl" ] && { printf '%s\n' "$d/${name}.tbl"; return 0; } + done + return 1 +} + +cmd_list() { + local site="${HCISITE:-}" + while [ $# -gt 0 ]; do case "$1" in --site) shift; site="$1" ;; esac; shift; done + for d in "${HCIROOT:-}/$site/tables" "${HCIROOT:-}/$site/Tables" "${HCIROOT:-}/Tables"; do + [ -d "$d" ] || continue + find "$d" -maxdepth 1 -name '*.tbl' -type f 2>/dev/null | sort + done | sort -u +} + +cmd_show() { + local name="$1"; shift + local site="${HCISITE:-}" + while [ $# -gt 0 ]; do case "$1" in --site) shift; site="$1" ;; esac; shift; done + local f; f=$(locate_table "$name" "$site") || die "no such table: $name" + cat "$f" +} + +cmd_pairs() { + local name="$1"; shift + local site="${HCISITE:-}" + local fmt="csv" + while [ $# -gt 0 ]; do case "$1" in + --site) shift; site="$1" ;; + --format) shift; fmt="$1" ;; + esac; shift; done + local f; f=$(locate_table "$name" "$site") || die "no such table: $name" + case "$fmt" in + csv) "$T2C" --with-header "$f" ;; + tsv) "$T2C" --with-header --delim $'\t' "$f" ;; + *) die "bad --format" ;; + esac +} + +cmd_lookup() { + local name="$1" input="$2" + local site="${HCISITE:-}" + local f; f=$(locate_table "$name" "$site") || die "no such table: $name" + "$T2C" "$f" | awk -F',' -v target="$input" ' + BEGIN { found=0 } + { + # CSV unquote (basic) + v=$1; gsub(/^"|"$/, "", v); gsub(/""/, "\"", v) + if (v == target) { o=$2; gsub(/^"|"$/, "", o); gsub(/""/, "\"", o); print o; found=1; exit } + } + END { if (!found) exit 1 } + ' +} + +cmd_reverse_lookup() { + local name="$1" output="$2" + local site="${HCISITE:-}" + local f; f=$(locate_table "$name" "$site") || die "no such table: $name" + "$T2C" "$f" | awk -F',' -v target="$output" ' + { + v=$2; gsub(/^"|"$/, "", v); gsub(/""/, "\"", v) + if (v == target) { i=$1; gsub(/^"|"$/, "", i); gsub(/""/, "\"", i); print i } + } + ' +} + +# Modification helpers (journal-backed) +modify_via_csv() { + local name="$1" csv_path="$2" site="$3" mode="$4" # mode = add|delete|replace|create + local action="$mode-table" + local target + + # Determine target path + if [ "$mode" = "create" ]; then + [ -n "$site" ] || die "create: --site required" + [ -d "${HCIROOT}/$site/tables" ] || mkdir -p "${HCIROOT}/$site/tables" + target="${HCIROOT}/$site/tables/${name%.tbl}.tbl" + [ ! -e "$target" ] || die "table already exists: $target (use replace if you want to overwrite)" + else + target=$(locate_table "$name" "$site") || die "no such table: $name" + fi + + local new; new=$(mktemp) + "$C2T" --has-header --out "$new" < "$csv_path" 2>/dev/null \ + || "$C2T" --has-header "$csv_path" > "$new" + + if declare -f journal_write >/dev/null 2>&1; then + journal_write "$target" "$new" + else + # No journal — direct write with simple backup + [ -f "$target" ] && cp -p "$target" "${target}.larry-bak.$(date +%s)" + mv "$new" "$target" + echo "(no journal available; backup at ${target}.larry-bak.)" + fi + rm -f "$new" +} + +cmd_add() { + local name="$1" input="$2" output="$3"; shift 3 + local site="${HCISITE:-}" + while [ $# -gt 0 ]; do case "$1" in --site) shift; site="$1" ;; esac; shift; done + [ -n "$name" ] && [ -n "$input" ] && [ -n "$output" ] || die "usage: add NAME INPUT OUTPUT" + local f; f=$(locate_table "$name" "$site") || die "no such table: $name" + + # Read current pairs, replace existing input row or append, then rewrite + local tmp_csv; tmp_csv=$(mktemp) + { + "$T2C" --with-header "$f" + # If the new pair wasn't already in the existing data, it'll be added below + } > "$tmp_csv" + + local awk_script=' + BEGIN { added=0 } + NR==1 { print; next } + { + n=split($0, c, ",") + v=c[1]; gsub(/^"|"$/, "", v); gsub(/""/, "\"", v) + if (v == NEW_IN) { + printf "%s,%s\n", NEW_IN, NEW_OUT + added=1 + } else { print } + } + END { if (!added) printf "%s,%s\n", NEW_IN, NEW_OUT } + ' + local newcsv; newcsv=$(mktemp) + awk -v NEW_IN="$input" -v NEW_OUT="$output" "$awk_script" "$tmp_csv" > "$newcsv" + modify_via_csv "$name" "$newcsv" "$site" "add" + rm -f "$tmp_csv" "$newcsv" +} + +cmd_delete() { + local name="$1" input="$2"; shift 2 + local site="${HCISITE:-}" + while [ $# -gt 0 ]; do case "$1" in --site) shift; site="$1" ;; esac; shift; done + [ -n "$name" ] && [ -n "$input" ] || die "usage: delete NAME INPUT" + local f; f=$(locate_table "$name" "$site") || die "no such table: $name" + local tmp_csv; tmp_csv=$(mktemp) + "$T2C" --with-header "$f" \ + | awk -F',' -v IN="$input" ' + NR==1 { print; next } + { + v=$1; gsub(/^"|"$/, "", v); gsub(/""/, "\"", v) + if (v == IN) next + print + } + ' > "$tmp_csv" + modify_via_csv "$name" "$tmp_csv" "$site" "delete" + rm -f "$tmp_csv" +} + +cmd_create() { + local name="$1"; shift + local from_csv="" + local site="${HCISITE:-}" + while [ $# -gt 0 ]; do case "$1" in + --from-csv) shift; from_csv="$1" ;; + --site) shift; site="$1" ;; + esac; shift; done + [ -f "$from_csv" ] || die "create: --from-csv FILE required" + modify_via_csv "$name" "$from_csv" "$site" "create" +} + +cmd_replace() { + local name="$1"; shift + local from_csv="" + local site="${HCISITE:-}" + while [ $# -gt 0 ]; do case "$1" in + --from-csv) shift; from_csv="$1" ;; + --site) shift; site="$1" ;; + esac; shift; done + [ -f "$from_csv" ] || die "replace: --from-csv FILE required" + modify_via_csv "$name" "$from_csv" "$site" "replace" +} + +SUB="${1:-list}" +case "$SUB" in + list) shift; cmd_list "$@" ;; + show) shift; [ $# -ge 1 ] || die "usage: show NAME"; cmd_show "$@" ;; + pairs) shift; [ $# -ge 1 ] || die "usage: pairs NAME"; cmd_pairs "$@" ;; + lookup) shift; [ $# -ge 2 ] || die "usage: lookup NAME INPUT"; cmd_lookup "$@" ;; + reverse-lookup) shift; [ $# -ge 2 ] || die "usage: reverse-lookup NAME OUTPUT"; cmd_reverse_lookup "$@" ;; + add) shift; cmd_add "$@" ;; + delete) shift; cmd_delete "$@" ;; + create) shift; cmd_create "$@" ;; + replace) shift; cmd_replace "$@" ;; + help|-h|--help) sed -n '2,20p' "$NC_SELF" ;; + *) die "unknown subcommand: $SUB" ;; +esac diff --git a/lib/nc-tclgen.sh b/lib/nc-tclgen.sh new file mode 100755 index 0000000..394efa9 --- /dev/null +++ b/lib/nc-tclgen.sh @@ -0,0 +1,292 @@ +#!/usr/bin/env bash +# nc-tclgen.sh — generate TCL UPOC scaffolding from intent. Skeletons for +# common Cloverleaf TPS/Xlate use cases. Output is ready-to-edit TCL. +# +# Larry can write your full UPOC for you via prompt; this tool gives you a +# clean, lint-free starting point with the right argument handling and +# boilerplate. Useful when offline or when you want to start from a template. +# +# Subcommands: +# tps-presc [--description TXT] PreSC TPS proc skeleton +# tps-postsc [--description TXT] PostSC TPS proc skeleton +# tps-iclkill [--description TXT] a proc that calls hcitpsmsgkill +# xlate-helper [--description TXT] xlate helper function skeleton +# trxid [--description TXT] trxid (routing key) extractor +# ack [--description TXT] raw ACK generator +# field-rewrite --segment SEG --field N --to VALUE small field setter +# list-templates +# +# Output: TCL source to stdout (or --out PATH). +set -o pipefail + +usage() { sed -n '2,20p' "$0"; exit 0; } +die() { printf 'nc-tclgen: %s\n' "$*" >&2; exit 1; } + +header() { + local proc="$1" desc="$2" template="$3" + cat </dev/null || date) +# Author: ${USER:-larry-anywhere} +######################################################################## + +EOF +} + +emit_tps_presc() { + local proc="$1" desc="${2:-PreSC handler — runs before service send}" + header "$proc" "$desc" "PreSC" + cat </dev/null + +OUT_FILE="" +PROC="" +DESC="" +SEG=""; FNUM=""; TO="" + +# Parse common flags +while [ $# -gt 0 ]; do + case "$1" in + --description) shift; DESC="$1" ;; + --out) shift; OUT_FILE="$1" ;; + --segment) shift; SEG="$1" ;; + --field) shift; FNUM="$1" ;; + --to) shift; TO="$1" ;; + -h|--help) usage ;; + -*) die "unknown flag: $1" ;; + *) [ -z "$PROC" ] && PROC="$1" || die "extra arg: $1" ;; + esac + shift +done + +run_emit() { + if [ -n "$OUT_FILE" ]; then + mkdir -p "$(dirname "$OUT_FILE")" 2>/dev/null + "$@" > "$OUT_FILE" + printf 'wrote %s\n' "$OUT_FILE" >&2 + else + "$@" + fi +} + +case "$SUB" in + tps-presc) [ -n "$PROC" ] || die "needs PROC name"; run_emit emit_tps_presc "$PROC" "$DESC" ;; + tps-postsc) [ -n "$PROC" ] || die "needs PROC name"; run_emit emit_tps_postsc "$PROC" "$DESC" ;; + tps-iclkill) [ -n "$PROC" ] || die "needs PROC name"; run_emit emit_tps_iclkill "$PROC" "$DESC" ;; + xlate-helper) [ -n "$PROC" ] || die "needs PROC name"; run_emit emit_xlate_helper "$PROC" "$DESC" ;; + trxid) [ -n "$PROC" ] || die "needs PROC name"; run_emit emit_trxid "$PROC" "$DESC" ;; + ack) [ -n "$PROC" ] || die "needs PROC name"; run_emit emit_ack "$PROC" "$DESC" ;; + field-rewrite) [ -n "$PROC" ] && [ -n "$SEG" ] && [ -n "$FNUM" ] && [ -n "$TO" ] \ + || die "needs PROC + --segment SEG --field N --to VALUE" + run_emit emit_field_rewrite "$PROC" "$SEG" "$FNUM" "$TO" ;; + list-templates|list) list_templates ;; + help|-h|--help) usage ;; + *) die "unknown subcommand: $SUB" ;; +esac diff --git a/lib/nc-xlate.sh b/lib/nc-xlate.sh new file mode 100755 index 0000000..8b01eec --- /dev/null +++ b/lib/nc-xlate.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# nc-xlate.sh — visualize and explore Cloverleaf xlate (.xlt) files. +# Parses the TCL nested-block format and renders operation flows. +# +# Subcommands: +# list [--site SITE] list .xlt files +# show [--site SITE] dump the raw file +# ops [--site SITE] list operations as TSV (op, in, out, err) +# tree [--site SITE] ASCII tree by op type +# summary [--site SITE] counts by operation + segments touched +# diff diff two xlates (semantic, sorted-by-op) +set -o pipefail + +NC_SELF="$0" + +die() { printf 'nc-xlate: %s\n' "$*" >&2; exit 1; } + +locate_xlate() { + local name="$1" site="${2:-${HCISITE:-}}" + name="${name%.xlt}" + for d in "${HCIROOT:-}/$site/Xlate" "${HCIROOT:-}/$site/xlate" "${HCIROOT:-}/Xlate"; do + [ -f "$d/${name}.xlt" ] && { printf '%s\n' "$d/${name}.xlt"; return 0; } + done + return 1 +} + +cmd_list() { + local site="${HCISITE:-}" + while [ $# -gt 0 ]; do case "$1" in --site) shift; site="$1" ;; esac; shift; done + for d in "${HCIROOT:-}/$site/Xlate" "${HCIROOT:-}/$site/xlate"; do + [ -d "$d" ] || continue + find "$d" -maxdepth 1 -name '*.xlt' -type f 2>/dev/null | sort + done +} + +cmd_show() { + local name="$1"; shift + local site="${HCISITE:-}" + while [ $# -gt 0 ]; do case "$1" in --site) shift; site="$1" ;; esac; shift; done + local f; f=$(locate_xlate "$name" "$site") || die "no such xlate: $name" + cat "$f" +} + +# Parse the .xlt operations. Each top-level block looks like: +# { { OP } +# { ERR } +# { IN } +# { OUT } +# [ { TABLE } ] +# [ ... ] +# } +# Emit TSV: op_num \t op \t in \t out \t err \t extra +parse_ops() { + local f="$1" + awk ' + BEGIN { depth=0; in_block=0; n=0 } + /^end_prologue$/ { in_pre=0; next } + /^prologue$/ { in_pre=1; next } + in_pre { next } + /^[[:space:]]*\{ \{ OP / { + depth=1; in_block=1 + op=""; in_path=""; out_path=""; err=""; extra="" + # First line has "{ { OP }" + match($0, /\{ OP [A-Z]+ \}/) + if (RSTART) { + op = substr($0, RSTART+5, RLENGTH-7) + } + next + } + in_block { + # Track depth via gsub-of-self trick + no = gsub(/\{/, "{", $0) + nc = gsub(/\}/, "}", $0) + depth += no - nc + + if (match($0, /\{ IN [^}]+\}/)) { in_path = substr($0, RSTART+5, RLENGTH-6); gsub(/[[:space:]]+$/, "", in_path) } + if (match($0, /\{ OUT [^}]+\}/)) { out_path = substr($0, RSTART+6, RLENGTH-7); gsub(/[[:space:]]+$/, "", out_path) } + if (match($0, /\{ ERR [^}]+\}/)) { err = substr($0, RSTART+6, RLENGTH-7); gsub(/[[:space:]]+$/, "", err) } + if (match($0, /\{ TABLE [^}]+\}/)) { extra = "TABLE=" substr($0, RSTART+8, RLENGTH-9); gsub(/[[:space:]]+$/, "", extra) } + + if (depth == 0) { + n++ + printf "%d\t%s\t%s\t%s\t%s\t%s\n", n, op, in_path, out_path, err, extra + in_block = 0 + } + } + ' "$f" +} + +cmd_ops() { + local name="$1"; shift + local site="${HCISITE:-}" + while [ $# -gt 0 ]; do case "$1" in --site) shift; site="$1" ;; esac; shift; done + local f; f=$(locate_xlate "$name" "$site") || die "no such xlate: $name" + printf 'num\top\tin\tout\terr\textra\n' + parse_ops "$f" +} + +cmd_tree() { + local name="$1"; shift + local site="${HCISITE:-}" + while [ $# -gt 0 ]; do case "$1" in --site) shift; site="$1" ;; esac; shift; done + local f; f=$(locate_xlate "$name" "$site") || die "no such xlate: $name" + printf 'Xlate: %s\n' "$name" + parse_ops "$f" | awk -F'\t' ' + { + op=$2; in_path=$3; out_path=$4; extra=$6 + if (in_path == "" && out_path == "") { printf " %d. %s\n", $1, op; next } + arrow = "→" + if (op == "MOVE") arrow = "↦" + if (op == "DELETE") arrow = "✗" + if (extra != "") arrow = "↦(" extra ")→" + printf " %2d. %-12s %-30s %s %s\n", $1, op, in_path, arrow, out_path + } + ' +} + +cmd_summary() { + local name="$1"; shift + local site="${HCISITE:-}" + while [ $# -gt 0 ]; do case "$1" in --site) shift; site="$1" ;; esac; shift; done + local f; f=$(locate_xlate "$name" "$site") || die "no such xlate: $name" + printf 'Xlate: %s (path: %s)\n\n' "$name" "$f" + printf 'Operations by type:\n' + parse_ops "$f" | awk -F'\t' 'NR>0 {c[$2]++} END {for (k in c) printf " %-12s %d\n", k, c[k]}' | sort -k2 -rn + printf '\nSegments touched (IN side):\n' + parse_ops "$f" | awk -F'\t' '{ + if (match($3, /\.[A-Z][A-Z0-9]+\(/)) { + seg = substr($3, RSTART+1, RLENGTH-2) + c[seg]++ + } + } END {for (k in c) printf " %-6s %d\n", k, c[k]}' | sort + printf '\nTables referenced:\n' + parse_ops "$f" | awk -F'\t' '$6 ~ /TABLE=/ { t=substr($6,7); print t }' | sort -u | sed 's/^/ /' +} + +cmd_diff() { + local n1="$1" n2="$2" + local f1 f2 + f1=$(locate_xlate "$n1") || die "no such xlate: $n1" + f2=$(locate_xlate "$n2") || die "no such xlate: $n2" + diff -u <(parse_ops "$f1" | sort -k2) <(parse_ops "$f2" | sort -k2) +} + +SUB="${1:-list}" +case "$SUB" in + list) shift; cmd_list "$@" ;; + show) shift; [ $# -ge 1 ] || die "usage: show NAME"; cmd_show "$@" ;; + ops) shift; [ $# -ge 1 ] || die "usage: ops NAME"; cmd_ops "$@" ;; + tree) shift; [ $# -ge 1 ] || die "usage: tree NAME"; cmd_tree "$@" ;; + summary) shift; [ $# -ge 1 ] || die "usage: summary NAME"; cmd_summary "$@" ;; + diff) shift; [ $# -ge 2 ] || die "usage: diff NAME1 NAME2"; cmd_diff "$@" ;; + help|-h|--help) sed -n '2,15p' "$NC_SELF" ;; + *) die "unknown subcommand: $SUB" ;; +esac