v0.8.31: nc_set_field — change a thread's PORT/HOST/PROCESSNAME/ENCODING (journaled)

New mutating tool, built on the proven journal/rollback foundation. Curated
safe field set only (rejects anything else; never creates a missing field).
Edits are line-number-anchored to the target thread's protocol block via
nc-parse (a shared port/host value in another thread is never touched),
brace-balance-checked before an atomic write, journaled for byte-identical
rollback. Flags: --dry-run (no write), --confirm yes, --site, --netconfig.
Copy-tested: PORT + HOST applied surgically, rollback byte-identical.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Bryan Johnson 2026-05-28 18:43:27 -07:00
parent 5bc3195f98
commit 7a715c802a
5 changed files with 423 additions and 5 deletions

View File

@ -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 Versioning is loose-semver; bumps trigger the in-process self-update on every
running client via `LARRY_BASE_URL` + `MANIFEST`. 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 <thread>[.<site>] <field> <value>` (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 ## v0.8.30 — 2026-05-28
**★ WRITE/MUTATE TOOL VALIDATION PASS + journal-rollback foundation verified — **★ WRITE/MUTATE TOOL VALIDATION PASS + journal-rollback foundation verified —

View File

@ -23,16 +23,16 @@
# scripts/make-manifest.sh and bump VERSION. # scripts/make-manifest.sh and bump VERSION.
# Top-level scripts # Top-level scripts
larry.sh 5f7c82ac08e6be85db8acaf314fcbdc394ac6182f5f784aba8540c05cb139172 larry.sh 940d8fad8bffc42f6b5b7e7b295f8218ee43c03f327ca250e71bbb7dbce1a002
larry-tunnel.sh 6b050e4eeab15669f4858eaf3b807f168f211ced07815db9521bc40a093f6aaa larry-tunnel.sh 6b050e4eeab15669f4858eaf3b807f168f211ced07815db9521bc40a093f6aaa
larry-auth.sh a220cdf7878569dc3028951ee57fc8d5e706a8ca5c6aa45347b58facb386f831 larry-auth.sh a220cdf7878569dc3028951ee57fc8d5e706a8ca5c6aa45347b58facb386f831
larry-rollback.sh 91b5e9aa6c79266bf306dcfba4ca791c07971bd6924d67a779037531648aa6d0 larry-rollback.sh 91b5e9aa6c79266bf306dcfba4ca791c07971bd6924d67a779037531648aa6d0
install-larry.sh fa36e23a39eacbd0d7ecedd3b42131902f816ee7e98241dfc6e28c6e4ba80423 install-larry.sh fa36e23a39eacbd0d7ecedd3b42131902f816ee7e98241dfc6e28c6e4ba80423
# Metadata # Metadata
VERSION fe9f7fe00061bf3b44b38df8fcaa8d09d689c6ee8be0fee57e7d3fe528d18e50 VERSION bae94dce70052efa657cca9bf24209ef8ae9cb277deb79f38e7fdbdfdc5bd254
MANUAL.md c64bd0251a51ad150508b4e1185355bc4826a64071d4de339f92ed550dbfacde MANUAL.md c64bd0251a51ad150508b4e1185355bc4826a64071d4de339f92ed550dbfacde
CHANGELOG.md 75b131794cc273ba1dba430637008c975b2532bfc2bcc99cc9fd65bd12701537 CHANGELOG.md 8696119944e16e8b7ab798d0641fb9f6beda48f871208132b7929401fc611d75
# Agent personas (system-prompt overlays) # Agent personas (system-prompt overlays)
agents/larry.md 0a1ef737e7fc133ab35be09f79c3a4df33de814e0404b69b950932d0c8a01be1 agents/larry.md 0a1ef737e7fc133ab35be09f79c3a4df33de814e0404b69b950932d0c8a01be1
@ -96,6 +96,7 @@ lib/nc-table.sh a6d5c11dd460cfb100ea50c74d57c1a46ef49112632037534a32cd28600abe7f
lib/nc-xlate.sh 8621e6f0ef55524dd6ecba91fee055cf9cdc168791e75ba7c15d9bf501fe09bf lib/nc-xlate.sh 8621e6f0ef55524dd6ecba91fee055cf9cdc168791e75ba7c15d9bf501fe09bf
lib/nc-smat-diff.sh 9c04d9e2f35f22c78d5f3c40a884ed23a3b6aaabc53ee27dfbfb66ab3166a567 lib/nc-smat-diff.sh 9c04d9e2f35f22c78d5f3c40a884ed23a3b6aaabc53ee27dfbfb66ab3166a567
lib/nc-create-thread.sh e35b0ee27f2c0327928adc21467f2b9d5a29f7436e5b89773f65420281739df7 lib/nc-create-thread.sh e35b0ee27f2c0327928adc21467f2b9d5a29f7436e5b89773f65420281739df7
lib/nc-set-field.sh 0977fcab1cd931ecdd78c8aea673db93d898f11f535daf26456a4fb7845542e4
lib/nc-tclgen.sh 5b8e73d7f6950a2b84f563132562ea82f62f4acac907257e233c7e68d85506c9 lib/nc-tclgen.sh 5b8e73d7f6950a2b84f563132562ea82f62f4acac907257e233c7e68d85506c9
lib/nc-parse.sh 52fef42d7a4b361534ab0d921deef74586dfeb6c199c941cebb55abcc2c39d4f lib/nc-parse.sh 52fef42d7a4b361534ab0d921deef74586dfeb6c199c941cebb55abcc2c39d4f
lib/nc-paths.sh 388d2f4560736587a01218cadc1de612cd59e392819d16db2f56f19174c1111b lib/nc-paths.sh 388d2f4560736587a01218cadc1de612cd59e392819d16db2f56f19174c1111b

View File

@ -1 +1 @@
0.8.30 0.8.31

View File

@ -78,7 +78,7 @@ set -o pipefail
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Config # Config
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
LARRY_VERSION="0.8.30" LARRY_VERSION="0.8.31"
LARRY_HOME="${LARRY_HOME:-$HOME/.larry}" 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 nc-table.sh|Read and modify Cloverleaf lookup tables (.tbl) — every write is backed up and auditable
#NetConfig (write) #NetConfig (write)
nc-create-thread.sh|High-level: create a new thread in a NetConfig (and optionally wire its route) 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-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-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) nc-tclgen.sh|Generate annotated TCL UPOC scaffolding (skeletons for common Cloverleaf proc patterns)
@ -1816,6 +1817,32 @@ tool_nc_add_route() {
return $rc 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() { tool_nc_regression() {
local scope="$1" count="$2" env_a="$3" site_a="$4" env_b="$5" site_b="$6" out_dir="$7" 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}" 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_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_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_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"')" ;; 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 // ""')" \ 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/")" \ "$(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 `<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"]}}, {"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"]}},
{"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_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_<tag>_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_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_<tag>_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":"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/<date>.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"]}}, {"name":"lesson_record","description":"Append a lesson to local capture at $LARRY_HOME/lessons/<date>.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"]}},

350
lib/nc-set-field.sh Executable file
View File

@ -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 <thread>[.<site>] <field> <value> [opts]
#
# <thread> bare thread → resolved in $HCISITE
# <thread>.<site> 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 <v> … } block's PORT
# HOST → the nested PROTOCOL { … HOST <v> … } 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 <netconfig> # newest-first
# larry-rollback.sh --entry <entry-id> # 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.<FIELD> 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 "<scope> <real_field>" 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:
# `<indent>{ FIELD <newval> }`
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 <thread>[.<site>] <field> <value> [--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 <thread>.<site>, --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:-<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/tty 2>/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