diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cb5ac9..a0e29ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,41 @@ All notable changes to `cloverleaf-larry` / `larry-anywhere` are recorded here. Versioning is loose-semver; bumps trigger the in-process self-update on every running client via `LARRY_BASE_URL` + `MANIFEST`. +## v0.8.31 — 2026-05-28 + +**★ NEW WRITE TOOL: `nc_set_field` — change a settable field (PORT, HOST/IP, +PROCESSNAME, ENCODING) on an existing thread, JOURNALED + rollback-reversible.** +Bryan's top-requested write feature ("changing port numbers and ip addresses"). +Built on the exact journal/atomic-write foundation the v0.8.30 mutate pass proved +byte-identical-reversible — same idiom as `nc-table.sh` / `nc-insert-protocol.sh` +(snapshot → diff → atomic write → journal entry; undo via `larry-rollback.sh`). + +- **Invocation:** `nc-set-field [.] ` (bare thread → + `$HCISITE`; `thread.site` cross-site; also `--site`). Flags: `--dry-run` (show + before→after, NO write), `--confirm yes` (skip the y/N prompt; still journaled), + `--netconfig PATH`, `--hciroot PATH`, `--completion` (emit a bash-completion + snippet: thread names + the field enum). +- **Curated safe set, explicit reject otherwise** — never blind-edits arbitrary + tokens and never CREATES a missing field: PORT → nested `PROTOCOL.PORT`; HOST + (alias IP) → nested `PROTOCOL.HOST`; PROCESSNAME → top-level; ENCODING → + top-level (must already exist). Field name is case-insensitive. +- **Anchored edit, NOT a global sed.** Locates the thread's protocol-block line + range via `nc-parse` (`protocol-line` + brace-balanced end walk), then the exact + field line within it (nested vs. top-level scoped), and replaces ONLY that value + token — preserving indentation + brace shape. A port/host SHARED by another + thread is provably untouched. Re-verifies balanced braces before the journal + write; a broken structure aborts with nothing written. No-op (value already set) + exits 4 cleanly. +- **Wired into all 4 larry.sh surfaces** (manual-tools registry, `tool_nc_set_field` + wrapper, `execute_tool` case, TOOLS_JSON schema) + the bash-completion snippet. +- Verified on a COPY of the real 24-site integrator (`/tmp/clvf_setfield_test`, + read fixture sha-confirmed untouched): dry-run shows before→after without writing; + a real PORT change (39500→39600) and HOST change (172.31.23.2→10.34.48.11) each + apply as a single surgical line edit (braces balanced, the two threads sharing + PORT 51205 untouched); both journaled; whole-session rollback restored the file + BYTE-IDENTICAL; an unsupported field (MLP_TIMEOUT, TYPE) and an unknown thread + are rejected cleanly. + ## v0.8.30 — 2026-05-28 **★ WRITE/MUTATE TOOL VALIDATION PASS + journal-rollback foundation verified — diff --git a/MANIFEST b/MANIFEST index 662b467..6cb57c0 100644 --- a/MANIFEST +++ b/MANIFEST @@ -23,16 +23,16 @@ # scripts/make-manifest.sh and bump VERSION. # Top-level scripts -larry.sh 5f7c82ac08e6be85db8acaf314fcbdc394ac6182f5f784aba8540c05cb139172 +larry.sh 940d8fad8bffc42f6b5b7e7b295f8218ee43c03f327ca250e71bbb7dbce1a002 larry-tunnel.sh 6b050e4eeab15669f4858eaf3b807f168f211ced07815db9521bc40a093f6aaa larry-auth.sh a220cdf7878569dc3028951ee57fc8d5e706a8ca5c6aa45347b58facb386f831 larry-rollback.sh 91b5e9aa6c79266bf306dcfba4ca791c07971bd6924d67a779037531648aa6d0 install-larry.sh fa36e23a39eacbd0d7ecedd3b42131902f816ee7e98241dfc6e28c6e4ba80423 # Metadata -VERSION fe9f7fe00061bf3b44b38df8fcaa8d09d689c6ee8be0fee57e7d3fe528d18e50 +VERSION bae94dce70052efa657cca9bf24209ef8ae9cb277deb79f38e7fdbdfdc5bd254 MANUAL.md c64bd0251a51ad150508b4e1185355bc4826a64071d4de339f92ed550dbfacde -CHANGELOG.md 75b131794cc273ba1dba430637008c975b2532bfc2bcc99cc9fd65bd12701537 +CHANGELOG.md 8696119944e16e8b7ab798d0641fb9f6beda48f871208132b7929401fc611d75 # Agent personas (system-prompt overlays) agents/larry.md 0a1ef737e7fc133ab35be09f79c3a4df33de814e0404b69b950932d0c8a01be1 @@ -96,6 +96,7 @@ lib/nc-table.sh a6d5c11dd460cfb100ea50c74d57c1a46ef49112632037534a32cd28600abe7f lib/nc-xlate.sh 8621e6f0ef55524dd6ecba91fee055cf9cdc168791e75ba7c15d9bf501fe09bf lib/nc-smat-diff.sh 9c04d9e2f35f22c78d5f3c40a884ed23a3b6aaabc53ee27dfbfb66ab3166a567 lib/nc-create-thread.sh e35b0ee27f2c0327928adc21467f2b9d5a29f7436e5b89773f65420281739df7 +lib/nc-set-field.sh 0977fcab1cd931ecdd78c8aea673db93d898f11f535daf26456a4fb7845542e4 lib/nc-tclgen.sh 5b8e73d7f6950a2b84f563132562ea82f62f4acac907257e233c7e68d85506c9 lib/nc-parse.sh 52fef42d7a4b361534ab0d921deef74586dfeb6c199c941cebb55abcc2c39d4f lib/nc-paths.sh 388d2f4560736587a01218cadc1de612cd59e392819d16db2f56f19174c1111b diff --git a/VERSION b/VERSION index 663a808..d076386 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.30 +0.8.31 diff --git a/larry.sh b/larry.sh index 59b6b10..7e882d6 100755 --- a/larry.sh +++ b/larry.sh @@ -78,7 +78,7 @@ set -o pipefail # ───────────────────────────────────────────────────────────────────────────── # Config # ───────────────────────────────────────────────────────────────────────────── -LARRY_VERSION="0.8.30" +LARRY_VERSION="0.8.31" LARRY_HOME="${LARRY_HOME:-$HOME/.larry}" # ───────────────────────────────────────────────────────────────────────────── @@ -350,6 +350,7 @@ nc-xlate.sh|Visualize and explore a Cloverleaf xlate (.xlt) file — the TCL nes nc-table.sh|Read and modify Cloverleaf lookup tables (.tbl) — every write is backed up and auditable #NetConfig (write) nc-create-thread.sh|High-level: create a new thread in a NetConfig (and optionally wire its route) +nc-set-field.sh|Change ONE settable field (PORT, HOST/IP, PROCESSNAME, ENCODING) on an existing thread — anchored to the right block, journaled, rollback-reversible nc-insert-protocol.sh|Low-level write side: insert/replace a protocol block in a NetConfig nc-make-jump.sh|Generate the 3-thread "jump" pattern for cross-environment data replay nc-tclgen.sh|Generate annotated TCL UPOC scaffolding (skeletons for common Cloverleaf proc patterns) @@ -1816,6 +1817,32 @@ tool_nc_add_route() { return $rc } +# nc_set_field — change ONE curated settable field (PORT, HOST/IP, PROCESSNAME, +# ENCODING) on an existing thread's NetConfig block. JOURNALED + rollback- +# reversible (same foundation as nc_insert_protocol / nc_add_route). The edit is +# anchored to the right thread's block and the right field via nc-parse — NOT a +# global sed — so a shared port/host on another thread is never touched. Rejects +# any non-curated field. dry_run previews before→after without writing; confirm +# defaults to 'yes' from the API path (the journal makes it reversible). +tool_nc_set_field() { + local thread="$1" field="$2" value="$3" site="${4:-}" netconfig="${5:-}" + local dry_run="${6:-0}" confirm="${7:-yes}" hciroot="${8:-${HCIROOT:-}}" + _lib_err_if_missing || return + [ -n "$thread" ] && [ -n "$field" ] && [ -n "$value" ] \ + || { echo "ERROR: nc_set_field needs thread, field, and value"; return 1; } + local args=("$thread" "$field" "$value") + [ -n "$site" ] && args+=(--site "$site") + [ -n "$netconfig" ] && args+=(--netconfig "$netconfig") + [ -n "$hciroot" ] && args+=(--hciroot "$hciroot") + if [ "$dry_run" = "1" ]; then + args+=(--dry-run) + elif [ "$confirm" = "yes" ]; then + args+=(--confirm yes) + fi + LARRY_SESSION_ID="${LARRY_SESSION_ID:-$SESSION_ID}" \ + "$LARRY_LIB_DIR/nc-set-field.sh" "${args[@]}" 2>&1 +} + tool_nc_regression() { local scope="$1" count="$2" env_a="$3" site_a="$4" env_b="$5" site_b="$6" out_dir="$7" local route_cmd="${8:-}" ignore="${9:-MSH.7}" phase="${10:-all}" dry_run="${11:-0}" @@ -4382,6 +4409,10 @@ execute_tool() { nc_find) tool_nc_find "$(J '.mode')" "$(J '.query')" "$(J '.format // "table"')" "$(J '.hciroot // ""')" ;; nc_insert_protocol) tool_nc_insert_protocol "$(J '.netconfig')" "$(J '.block')" "$(J '.mode // "end"')" "$(J '.anchor // ""')" ;; nc_add_route) tool_nc_add_route "$(J '.netconfig')" "$(J '.protocol_name')" "$(J '.route')" ;; + nc_set_field) tool_nc_set_field "$(J '.thread')" "$(J '.field')" "$(J '.value')" \ + "$(J '.site // ""')" "$(J '.netconfig // ""')" \ + "$(J '.dry_run // 0' | sed "s/false/0/;s/true/1/")" \ + "$(J '.confirm // "yes"')" "$(J '.hciroot // ""')" ;; hl7_diff) tool_hl7_diff "$(J '.left')" "$(J '.right')" "$(J '.ignore // "MSH.7"')" "$(J '.include // ""')" "$(J '.format // "text"')" ;; nc_diff_interface) tool_nc_diff_interface "$(J '.interface')" "$(J '.left')" "$(J '.right')" "$(J '.out // ""')" \ "$(J '.include_tables // 0' | sed "s/false/0/;s/true/1/")" \ @@ -4444,6 +4475,7 @@ TOOLS_JSON=$(cat <<'TOOLS_END' {"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"]}}, {"name":"nc_insert_protocol","description":"Insert a new protocol block into a NetConfig file. ALL WRITES GO THROUGH THE JOURNAL — original is snapshotted, diff is saved, the file is atomically replaced. Use larry_rollback_list to view, larry-rollback.sh CLI to undo. mode=end appends; mode=after needs anchor=existing-protocol-name; mode=before needs anchor.","input_schema":{"type":"object","properties":{"netconfig":{"type":"string","description":"Target NetConfig file path."},"block":{"type":"string","description":"The full protocol block text (starting with 'protocol NAME {' and ending with '}'). Get this from nc_make_jump output."},"mode":{"type":"string","enum":["end","after","before"],"description":"Insertion position. Default end."},"anchor":{"type":"string","description":"For mode=after|before: existing protocol name to position relative to."}},"required":["netconfig","block"]}}, {"name":"nc_add_route","description":"Splice a route entry into an existing protocol's DATAXLATE block. Used to add a new DEST to an inbound's routing (e.g. wiring the OLD inbound to also route to the new linux__out jump thread). ALL WRITES GO THROUGH THE JOURNAL.","input_schema":{"type":"object","properties":{"netconfig":{"type":"string"},"protocol_name":{"type":"string","description":"The existing protocol to modify."},"route":{"type":"string","description":"The route entry text (an inner `{ ... }` object with CACHEMSG, ROUTE_DETAILS, TRXID, etc.). Get from nc_make_jump's route_add output."}},"required":["netconfig","protocol_name","route"]}}, + {"name":"nc_set_field","description":"Change ONE settable field on an EXISTING thread's NetConfig protocol block — Bryan's top write feature: changing PORT numbers and HOST/IP addresses on a live interface, safely. JOURNALED + ROLLBACK-REVERSIBLE on the same foundation as nc_insert_protocol/nc_add_route (snapshot + atomic write; undo with larry-rollback.sh, view with larry_rollback_list). The edit is ANCHORED to the named thread's block and the named field via the native parser — NOT a global sed — so a port/host value SHARED by another thread is never touched. CURATED SAFE SET ONLY (anything else is rejected with a clear error; it will NOT blind-edit arbitrary tokens, and it will NOT create a missing field): PORT (the nested PROTOCOL.PORT), HOST (the nested PROTOCOL.HOST; alias IP), PROCESSNAME (top-level), ENCODING (top-level, must already exist). USE THIS for 'change the port on thread X to N', 'point X at a new IP/host', 'move X to process P', 'set the encoding'. SAFETY: set dry_run=true to preview the before→after WITHOUT writing; otherwise confirm defaults to yes (the journal keeps it reversible). Resolves the NetConfig from thread+site under $HCIROOT (or pass netconfig explicitly). Thread may be a bare name (resolved in site/$HCISITE) or 'thread.site'.","input_schema":{"type":"object","properties":{"thread":{"type":"string","description":"Thread/protocol name to edit. Bare name (resolved in site or $HCISITE) or 'thread.site' for cross-site."},"field":{"type":"string","enum":["PORT","HOST","IP","PROCESSNAME","ENCODING"],"description":"Which curated field to change. PORT/HOST(=IP) live in the nested PROTOCOL{} block; PROCESSNAME/ENCODING are top-level. Any other field is rejected."},"value":{"type":"string","description":"The new value, e.g. 39600 (PORT) or 10.34.48.11 (HOST)."},"site":{"type":"string","description":"Site (the NetConfig's parent dir). Optional alt to thread.site; defaults to $HCISITE."},"netconfig":{"type":"string","description":"Explicit NetConfig path. Overrides site-based resolution."},"dry_run":{"type":"boolean","description":"true = show the before→after WITHOUT writing. Default false."},"confirm":{"type":"string","description":"'yes' (default) skips the interactive y/N prompt — still journaled/reversible. Set anything else to require an interactive confirm (only meaningful in a tty)."},"hciroot":{"type":"string","description":"Override $HCIROOT for site resolution."}},"required":["thread","field","value"]}}, {"name":"larry_rollback_list","description":"List journal entries — every write that's gone through nc_insert_protocol, nc_add_route, or write_file (once journaled write_file is enabled). Shows session-id, sequence, target, timestamp. Use larry-rollback.sh from the shell to actually roll back.","input_schema":{"type":"object","properties":{"session":{"type":"string","description":"Optional. Limit to one session id."}},"required":[]}}, {"name":"lesson_record","description":"Append a lesson to local capture at $LARRY_HOME/lessons/.md. Use when Bryan teaches you something new (a correction, a pattern, a quirk, a gotcha) so the home-Larry can be updated later. Lessons stay LOCAL; Bryan exports them with `lessons.sh export` and pastes back to home-Larry when he can. CALL THIS WHEN: Bryan corrects a misunderstanding, reveals a site-specific convention, points out a bug, requests a behavior change, or shares a workflow detail you should remember next time.","input_schema":{"type":"object","properties":{"text":{"type":"string","description":"The lesson content. Markdown. Include enough context that home-Larry can act on it without re-deriving."},"topic":{"type":"string","description":"Short topic tag, e.g. \"NetConfig parsing\", \"jump-thread naming\", \"site conventions\"."},"site":{"type":"string","description":"Site this lesson is scoped to, if any. Default: current $HCISITE."},"severity":{"type":"string","enum":["info","warn","fix"],"description":"info=general learning, warn=behavior I should change, fix=Bryan called out a bug."}},"required":["text"]}}, diff --git a/lib/nc-set-field.sh b/lib/nc-set-field.sh new file mode 100755 index 0000000..7c0d119 --- /dev/null +++ b/lib/nc-set-field.sh @@ -0,0 +1,350 @@ +#!/usr/bin/env bash +# nc-set-field.sh — change ONE settable field on an existing thread's NetConfig +# protocol block, JOURNALED so it's rollback-reversible. +# +# Bryan's top-requested write feature: changing port numbers and IP addresses on +# an existing interface, safely and auditably. Built on the SAME journal/atomic- +# write foundation proven byte-identical-reversible by the v0.8.30 write/mutate +# pass — identical pattern to nc-table.sh / nc-insert-protocol.sh. +# +# Invocation: +# nc-set-field [.] [opts] +# +# bare thread → resolved in $HCISITE +# . cross-site form (e.g. ADTto_CodaMetrix.ancout) +# +# Options: +# --site SITE site override (alt to thread.site form) +# --dry-run show the before→after WITHOUT writing +# --confirm yes skip the interactive y/N prompt (still JOURNALED) +# --netconfig PATH explicit NetConfig path (overrides site resolution) +# --hciroot PATH override $HCIROOT for site resolution +# --completion emit a bash-completion snippet (thread names + field enum) +# +# SUPPORTED FIELDS (curated + explicit — anything else is REJECTED with a clear +# error; this tool NEVER blind-edits an arbitrary token): +# PORT → the nested PROTOCOL { … PORT … } block's PORT +# HOST → the nested PROTOCOL { … HOST … } block's HOST (alias: IP) +# IP → alias for HOST +# PROCESSNAME → the protocol block's top-level PROCESSNAME +# ENCODING → the protocol block's top-level ENCODING (must already exist) +# +# The edit is ANCHORED to the right thread's block and the right field via +# nc-parse.sh (protocol-block range + the field's canonical one-line render). +# It replaces ONLY that value token, preserves the surrounding TCL brace +# structure, and re-verifies balanced braces before the journal write. If the +# field is not present in the thread's block it DIES (it will not CREATE the +# field — that is nc_add_route / nc-insert-protocol territory). +# +# Every write goes through journal.sh (snapshot + diff + atomic write). Undo: +# larry-rollback.sh --target # newest-first +# larry-rollback.sh --entry # one specific write +# +# Exit codes: 0 OK, 1 generic error, 2 usage, 3 target not found, 4 no change. +set -o pipefail + +NC_SELF="$0" +LIB_DIR="$(cd "$(dirname "$NC_SELF")" && pwd)" +NCP="$LIB_DIR/nc-parse.sh" +JOURNAL="$LIB_DIR/journal.sh" + +die() { printf 'nc-set-field: %s\n' "$*" >&2; exit 1; } + +# v0.8.31: shared CR-safety primitives + the tty-gated control-byte sanitizer. +# awk-emitted line numbers feed head/tail arithmetic; Cygwin awk.exe can taint +# them with a trailing CR. _sanitize_ctl_tty keeps human-readable output from +# corrupting a terminal while passing raw on a pipe. +if [ -r "$LIB_DIR/cygwin-safe.sh" ]; then + # shellcheck disable=SC1090,SC1091 + . "$LIB_DIR/cygwin-safe.sh" +else + coerce_int() { local r="${1:-}" d="${2:-0}" c; c=$(printf '%s' "$r" | tr -cd '0-9'); printf '%s' "${c:-$d}"; } + _sanitize_ctl_tty() { cat; } +fi + +# Source journal so we can call journal_write directly (same idiom as +# nc-insert-protocol.sh) — keeps entries in the running session. +# shellcheck disable=SC1090 +[ -r "$JOURNAL" ] && . "$JOURNAL" + +# ───────────────────────────────────────────────────────────────────────────── +# FIELD MAP — friendly name → exact NetConfig location. +# nested : value lives at PROTOCOL. inside the inner { PROTOCOL { … } } +# toplevel: value lives as a depth-1 { FIELD value } in the protocol block +# Anything not in this map is rejected; we never blind-edit arbitrary tokens. +# ───────────────────────────────────────────────────────────────────────────── +# Returns " " on stdout, non-zero if unsupported. +resolve_field() { + case "$1" in + PORT) printf 'nested PORT' ;; + HOST|IP) printf 'nested HOST' ;; + PROCESSNAME) printf 'toplevel PROCESSNAME' ;; + ENCODING) printf 'toplevel ENCODING' ;; + *) return 1 ;; + esac +} + +# Block line range for a protocol: "START,END" (1-based, inclusive). Reuses +# nc-parse's _blocks via protocol-line + brace-balanced end walk so the edit is +# scoped to exactly this thread. +block_range() { + local nc="$1" name="$2" start end + start=$("$NCP" protocol-line "$nc" "$name" 2>/dev/null) + start=$(coerce_int "$start" 0) + [ "$start" -gt 0 ] || return 1 + end=$(awk -v s="$start" ' + NR == s { depth = 1; in_block = 1; next } + in_block { + n_open = gsub(/\{/, "{", $0) + n_close = gsub(/\}/, "}", $0) + depth += n_open - n_close + if (depth == 0) { print NR; exit } + } + ' "$nc") + end=$(coerce_int "$end" 0) + [ "$end" -ge "$start" ] || return 1 + printf '%s,%s\n' "$start" "$end" +} + +# Locate the exact line number of the target field WITHIN [start,end]. +# scope=nested → inside the inner { PROTOCOL { … } } sub-block, the canonical +# one-line `{ FIELD value }` at that depth. +# scope=toplevel → the depth-1 `{ FIELD value }` directly under `protocol N {`. +# Prints the absolute line number, or nothing if not found. +field_line() { + local nc="$1" start="$2" end="$3" scope="$4" field="$5" + awk -v S="$start" -v E="$end" -v SCOPE="$scope" -v F="$field" ' + BEGIN { depth = 0; in_proto = 0; proto_depth = 0 } + NR < S { next } + NR > E { exit } + { + line = $0 + prev = depth + # Detect entry into the nested { PROTOCOL { ... } } sub-block. At NR==S the + # protocol-block opener `protocol N {` set depth to 1 conceptually; we track + # depth deltas from there. A line ` { PROTOCOL {` is the nested opener. + is_proto_open = (line ~ /^[[:space:]]+\{ PROTOCOL \{$/) + + if (SCOPE == "toplevel") { + # depth-1 field statement: ` { FIELD value }` entirely on one line, + # at the protocol-block top level (one indent in). prev-depth bookkeeping + # mirrors nc-parse cmd_protocol_field (baseline 1). + if (prev == 1 && line ~ ("^[[:space:]]+\\{ " F " ")) { print NR; found=1; exit } + } else { + # nested: only consider lines once we are inside { PROTOCOL { ... } }. + if (in_proto && line ~ ("^[[:space:]]+\\{ " F " ")) { print NR; found=1; exit } + } + + n_open = gsub(/\{/, "{", line) + n_close = gsub(/\}/, "}", line) + depth += n_open - n_close + + if (is_proto_open && !in_proto) { in_proto = 1; proto_depth = prev + 1 } + else if (in_proto && depth < proto_depth) { in_proto = 0 } + } + END { if (!found) exit 1 } + ' "$nc" +} + +# Extract the current value token from a ` { FIELD value }` line. +# Strips the leading `{ FIELD ` and trailing ` }`. Leaves the raw value +# (which may itself be `{}` or a braced list — we replace the whole token). +extract_value() { + local line="$1" field="$2" + line="${line#"${line%%[![:space:]]*}"}" # ltrim + line="${line#\{ "$field" }" # drop "{ FIELD " + line="${line%"${line##*[![:space:]]}"}" # rtrim (drops space before }) + line="${line%\}}" # drop the single trailing "}" + line="${line%"${line##*[![:space:]]}"}" # rtrim again (space between value and }) + printf '%s' "$line" +} + +# Build a replacement line preserving the original indentation + brace shape: +# `{ FIELD }` +build_line() { + local orig="$1" field="$2" newval="$3" indent + indent="${orig%%[![:space:]]*}" # leading whitespace + printf '%s{ %s %s }' "$indent" "$field" "$newval" +} + +# Verify balanced braces across the whole file (open == close). Cheap structural +# guard run AFTER the edit, BEFORE the journal write. +braces_balanced() { + awk '{ o += gsub(/\{/, "{"); c += gsub(/\}/, "}") } END { exit (o==c)?0:1 }' "$1" +} + +emit_completion() { + cat <<'COMP' +# bash completion for `larry tools nc-set-field` and a standalone `nc-set-field`. +# Source this file (or `eval "$(larry tools nc-set-field --completion)"`). +# Completes: thread names (from the resolved NetConfig) for arg 1, and the +# curated field enum for arg 2. +_nc_set_field_complete() { + local cur prev words cword + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + local nfields="PORT HOST IP PROCESSNAME ENCODING" + # Find the lib dir (next to larry.sh) to call nc-parse for thread names. + local lib="${LARRY_LIB_DIR:-}" + [ -z "$lib" ] && lib="$(dirname "$(command -v nc-set-field.sh 2>/dev/null || echo .)")" + local nc="${HCIROOT:-}/${HCISITE:-}/NetConfig" + case "$COMP_CWORD" in + 1) # thread name (optionally bare; thread.site also fine) + if [ -f "$nc" ] && [ -x "$lib/nc-parse.sh" ]; then + local threads; threads=$("$lib/nc-parse.sh" list-protocols "$nc" 2>/dev/null) + COMPREPLY=( $(compgen -W "$threads" -- "$cur") ) + fi ;; + 2) COMPREPLY=( $(compgen -W "$nfields" -- "$cur") ) ;; + esac +} +complete -F _nc_set_field_complete nc-set-field nc-set-field.sh +COMP +} + +# ───────────────────────────────────────────────────────────────────────────── +# Main +# ───────────────────────────────────────────────────────────────────────────── +THREAD=""; FIELD_IN=""; VALUE="" +SITE="${HCISITE:-}"; NC=""; HCIROOT_OVR="${HCIROOT:-}" +DRY=0; CONFIRM="" + +POSARGS=() +while [ $# -gt 0 ]; do + case "$1" in + --site) shift; SITE="$1" ;; + --netconfig) shift; NC="$1" ;; + --hciroot) shift; HCIROOT_OVR="$1" ;; + --dry-run) DRY=1 ;; + --confirm) shift; CONFIRM="$1" ;; + --completion) emit_completion; exit 0 ;; + -h|--help) sed -n '2,43p' "$NC_SELF"; exit 0 ;; + --*) die "unknown flag: $1" ;; + *) POSARGS+=("$1") ;; + esac + shift +done + +[ "${#POSARGS[@]}" -ge 3 ] || { echo "usage: nc-set-field [.] [--site S] [--dry-run] [--confirm yes] [--netconfig PATH] [--hciroot PATH]" >&2; exit 2; } +THREAD="${POSARGS[0]}"; FIELD_IN="${POSARGS[1]}"; VALUE="${POSARGS[2]}" + +# Parse thread.site form (the dot-suffix wins over a bare --site default, but a +# site supplied via thread.site AND --site that disagree is an error). +if [[ "$THREAD" == *.* ]]; then + dot_site="${THREAD##*.}" + THREAD="${THREAD%.*}" + if [ -n "$SITE" ] && [ "$SITE" != "${HCISITE:-}" ] && [ "$SITE" != "$dot_site" ]; then + die "site conflict: thread says '.$dot_site' but --site says '$SITE'" + fi + SITE="$dot_site" +fi + +# Field validation (curated set ONLY — explicit reject otherwise). +FIELD_IN_UC=$(printf '%s' "$FIELD_IN" | tr '[:lower:]' '[:upper:]') +RESOLVED=$(resolve_field "$FIELD_IN_UC") \ + || die "unsupported field: '$FIELD_IN'. Supported: PORT, HOST (alias IP), PROCESSNAME, ENCODING. (This tool only edits a curated safe set; it will not blind-edit arbitrary NetConfig tokens.)" +SCOPE="${RESOLVED%% *}" +REAL_FIELD="${RESOLVED##* }" + +[ -n "$VALUE" ] || die "value is required (got empty)" + +# Resolve the NetConfig path. +if [ -z "$NC" ]; then + [ -n "$SITE" ] || die "no site: pass ., --site SITE, or set \$HCISITE (or give --netconfig PATH)" + [ -n "$HCIROOT_OVR" ] || die "no \$HCIROOT (and no --hciroot / --netconfig). Cannot resolve the site's NetConfig." + NC="$HCIROOT_OVR/$SITE/NetConfig" +fi +[ -f "$NC" ] || { printf 'nc-set-field: no such NetConfig: %s\n' "$NC" >&2; exit 3; } + +# Confirm the thread exists. +"$NCP" list-protocols "$NC" 2>/dev/null | grep -qx "$THREAD" \ + || { printf 'nc-set-field: no such thread in %s: %s\n' "$NC" "$THREAD" >&2; exit 3; } + +# Find the thread's protocol-block line range. +RANGE=$(block_range "$NC" "$THREAD") || die "could not determine block range for thread $THREAD" +B_START="${RANGE%,*}"; B_END="${RANGE#*,}" + +# Locate the exact field line within that block. +TARGET_LINE=$(field_line "$NC" "$B_START" "$B_END" "$SCOPE" "$REAL_FIELD") +TARGET_LINE=$(coerce_int "$TARGET_LINE" 0) +if [ "$TARGET_LINE" -le 0 ]; then + case "$SCOPE" in + nested) die "field $REAL_FIELD not found in thread $THREAD's nested PROTOCOL{} block (looked in $NC lines $B_START-$B_END). This tool changes EXISTING fields only — it will not create one." ;; + toplevel) die "top-level field $REAL_FIELD not found in thread $THREAD's protocol block (looked in $NC lines $B_START-$B_END). This tool changes EXISTING fields only — it will not create one." ;; + esac +fi + +ORIG_LINE=$(sed -n "${TARGET_LINE}p" "$NC") +OLD_VALUE=$(extract_value "$ORIG_LINE" "$REAL_FIELD") +NEW_LINE=$(build_line "$ORIG_LINE" "$REAL_FIELD" "$VALUE") + +# Display form of the old value: an empty value renders as the Tcl empty list +# `{}` (computed separately — inlining `${OLD_VALUE:-{}}` mis-parses the braces). +OLD_DISP="$OLD_VALUE" +[ -z "$OLD_DISP" ] && OLD_DISP='{}' + +# No-op guard. +if [ "$OLD_VALUE" = "$VALUE" ]; then + printf 'nc-set-field: %s.%s %s is already %s — nothing to change.\n' \ + "$THREAD" "$SITE" "$FIELD_IN_UC" "$VALUE" | _sanitize_ctl_tty + exit 4 +fi + +# Show the before→after (always — for dry-run AND the confirm prompt). +{ + printf '\n=== %s (%s) %s ===\n' "$THREAD" "${SITE:-}" "$NC" + printf 'field: %s (%s %s, line %s)\n' "$FIELD_IN_UC" "$SCOPE" "$REAL_FIELD" "$TARGET_LINE" + printf 'before: %s\n' "$ORIG_LINE" + printf 'after: %s\n' "$NEW_LINE" + printf ' %s → %s\n\n' "$OLD_DISP" "$VALUE" +} | _sanitize_ctl_tty + +if [ "$DRY" = "1" ]; then + printf '(dry-run; no write made)\n' | _sanitize_ctl_tty + exit 0 +fi + +# Build the candidate file: lines 1..(target-1) + new line + (target+1)..EOF. +TMP=$(mktemp) +head -n $((TARGET_LINE - 1)) "$NC" > "$TMP" +printf '%s\n' "$NEW_LINE" >> "$TMP" +tail -n +$((TARGET_LINE + 1)) "$NC" >> "$TMP" + +# Structural guard: braces must remain balanced (open == close). +if ! braces_balanced "$TMP"; then + rm -f "$TMP" + die "post-edit brace check FAILED (open != close) — refusing to write. The edit would have broken the TCL structure; nothing was changed." +fi + +# Confirm unless --confirm yes. +if [ "$CONFIRM" != "yes" ]; then + printf 'Apply this change? [y/N]: ' | _sanitize_ctl_tty + ans="" + if declare -f read_clean >/dev/null 2>&1; then + read_clean ans + else + read -r ans /dev/null || ans="" + ans="${ans//$'\r'/}" + fi + if ! [[ "$ans" =~ ^[Yy]$ ]]; then + rm -f "$TMP" + printf 'aborted (no change)\n' | _sanitize_ctl_tty + exit 1 + fi +fi + +# Journaled atomic write. +if declare -f journal_write >/dev/null 2>&1; then + ENTRY_ID=$(journal_write "$NC" "$TMP") + rm -f "$TMP" + { + printf '\nset %s.%s %s = %s (was %s)\n' "$THREAD" "${SITE:-?}" "$FIELD_IN_UC" "$VALUE" "$OLD_DISP" + printf 'journal entry: %s\n' "$ENTRY_ID" + printf 'rollback: larry-rollback.sh --entry %s OR larry-rollback.sh --target %s\n' "$ENTRY_ID" "$NC" + } | _sanitize_ctl_tty +else + # No journal available — degrade with a plain backup (mirrors nc-table.sh). + _ts=$(date +%s | tr -cd '0-9') + cp -p "$NC" "${NC}.larry-bak.${_ts:-0}" + mv -f "$TMP" "$NC" + printf '(no journal available; backup at %s.larry-bak.%s)\n' "$NC" "${_ts:-0}" | _sanitize_ctl_tty +fi