diff --git a/DOC/AGENT_Context_Project.md b/DOC/AGENT_Context_Project.md index e333b09..3ccc3c2 100644 --- a/DOC/AGENT_Context_Project.md +++ b/DOC/AGENT_Context_Project.md @@ -628,7 +628,7 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:` | `lib/menu-lib.sh` | 362 | Category-neutral interactive menu primitives (`menu_guard` tty guard, `menu_run` looping boxed menu, `menu_pick` type-to-filter picker, `menu_ask_value` prompt-with-default via raw-mode bracketed-paste-safe `menu_read_value`; stderr render, fail-closed on non-tty/EOF) — sourced by `share-lib.sh`, open to any category | | `lib/registry.sh` | 199 | Shared query API for POS tool metadata headers (`# POS_*:`) — `reg_scan`/`reg_list`/`reg_lookup`/`reg_each`/config scope helpers; used by `pos-tree` and `gen-docs.sh` | | `lib/yt-lib.sh` | 50 | Shared YouTube helpers for `pos media yt *` (`yt_check_deps`, `yt_validate_url`, `yt_echo_cmd`, `classify_url`) — sourced by `yt-mp3`/`yt-mp4`/`yt-grab`/`yt-subtitles` | -| `lib/bank-lib.sh` | 175 | Shared Command Bank storage helpers for `pos bank` (`bank_load`/`bank_save`/`bank_add`/`bank_remove`/`bank_update`, `{param}` template substitution; store `~/.config/linux_post_install/bank.env`) — sourced by `pos-bank` | +| `lib/bank-lib.sh` | 183 | Shared Command Bank storage helpers for `pos bank` (`bank_load`/`bank_save`/`bank_add`/`bank_remove`/`bank_update`, `{param}` template substitution, v2 `\n`-escaped multiline storage with backward-compat raw load; store `~/.config/linux_post_install/bank.env`) — sourced by `pos-bank` | | `bin/flag-reader` | 58 | Inspect flags (list/status/`--raw`) | | `bin/flag-set` | 21 | Set a flag (optionally with a value) | | `bin/flag-clear` | 21 | Unset a flag | @@ -686,7 +686,7 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:` | `bin/pos-system-schedule` | 151 | Scheduled jobs: run a command on a timer; notify on threshold/change/error/always or silently | | `bin/pos-system-uninstall` | 518 | Remove pos toolkit binaries, services, shell integration, config, and data | | `bin/pos-ai` | 714 | AI assistant: ask, chat, sessions, capture, models, providers | -| `bin/pos-bank` | 314 | Persistent command bank for saving and running shell commands | +| `bin/pos-bank` | 311 | Persistent command bank for saving and running shell commands | | `bin/pos-config` | 80 | Interactive editor for the tools' runtime config (reads # POS_CONFIG: registry) | | `bin/pos-tree` | 118 | Show the pos CLI command tree: categories, commands, and subcommands | | `completions/pos.bash` | 331 | Dynamic bash completion | diff --git a/bin/pos-bank b/bin/pos-bank index cabb0e7..bd5efae 100755 --- a/bin/pos-bank +++ b/bin/pos-bank @@ -114,12 +114,11 @@ cmd_show() { [ -n "${1:-}" ] || err "Usage: pos bank show " local name="$1" bank_load - local entry - entry="$(bank_get "$name")" || err "Command not found: $name" + bank_find "$name" >/dev/null 2>&1 || err "Command not found: $name" local cmd_name cmd_desc cmd_cmd - cmd_name="$(printf '%s' "$entry" | cut -f1)" - cmd_desc="$(printf '%s' "$entry" | cut -f2)" - cmd_cmd="$(printf '%s' "$entry" | cut -f3)" + cmd_name="${BANK_NAMES[$BANK_IDX]}" + cmd_desc="${BANK_DESCS[$BANK_IDX]}" + cmd_cmd="${BANK_CMDS[$BANK_IDX]}" echo echo "Name: $cmd_name" echo @@ -162,12 +161,11 @@ cmd_run() { done fi bank_load - local entry - entry="$(bank_get "$name")" || err "Command not found: $name" + bank_find "$name" >/dev/null 2>&1 || err "Command not found: $name" local cmd_name cmd_desc cmd_cmd - cmd_name="$(printf '%s' "$entry" | cut -f1)" - cmd_desc="$(printf '%s' "$entry" | cut -f2)" - cmd_cmd="$(printf '%s' "$entry" | cut -f3)" + cmd_name="${BANK_NAMES[$BANK_IDX]}" + cmd_desc="${BANK_DESCS[$BANK_IDX]}" + cmd_cmd="${BANK_CMDS[$BANK_IDX]}" # Detect parameters local params_str params_str="$(_extract_params "$cmd_cmd")" @@ -218,12 +216,11 @@ cmd_edit() { name="$(_pick_command)" || return 0 fi bank_load - local entry - entry="$(bank_get "$name")" || err "Command not found: $name" + bank_find "$name" >/dev/null 2>&1 || err "Command not found: $name" local old_name old_desc old_cmd - old_name="$(printf '%s' "$entry" | cut -f1)" - old_desc="$(printf '%s' "$entry" | cut -f2)" - old_cmd="$(printf '%s' "$entry" | cut -f3)" + old_name="${BANK_NAMES[$BANK_IDX]}" + old_desc="${BANK_DESCS[$BANK_IDX]}" + old_cmd="${BANK_CMDS[$BANK_IDX]}" echo log "Editing: $old_name" echo diff --git a/lib/bank-lib.sh b/lib/bank-lib.sh index 4bf7866..6e06660 100644 --- a/lib/bank-lib.sh +++ b/lib/bank-lib.sh @@ -4,6 +4,8 @@ # # Storage: ~/.config/linux_post_install/bank.env # Format: name|description|command +# v2: commands with real newlines are saved with \\ (backslash) and \n +# (newline) escapes; files written by v1 have no marker and load raw. # # Contracts: # * Defines ONLY bank_* functions — sourcing never clobbers a tool's helpers. @@ -20,12 +22,13 @@ bank_load() { BANK_NAMES=(); BANK_DESCS=(); BANK_CMDS=() [ -f "$BANK_FILE" ] || return 0 - local line name desc cmd + local line name desc cmd v2=0 while IFS= read -r line; do - [[ "$line" =~ ^[[:space:]]*# ]] && continue + [[ "$line" =~ ^[[:space:]]*# ]] && { [[ "$line" == *"BANK_VERSION: 2"* ]] && v2=1; continue; } [[ -z "${line// /}" ]] && continue IFS='|' read -r name desc cmd <<< "$line" [[ -z "$name" ]] && continue + [ "$v2" -eq 1 ] && cmd="$(printf '%b' "$cmd")" BANK_NAMES+=("$name") BANK_DESCS+=("${desc:-}") BANK_CMDS+=("${cmd:-}") @@ -42,10 +45,15 @@ bank_save() { tmp="$(mktemp "${dir}/.bank.XXXXXX")" { printf '%s\n' "# Command Bank — managed by pos bank (do not hand-edit)" - printf '%s\n' "# Format: name|description|command" + printf '%s\n' "# Format: name|description|command (\\n = escaped newline in command)" + printf '%s\n' "# BANK_VERSION: 2" local i for ((i = 0; i < ${#BANK_NAMES[@]}; i++)); do - printf '%s|%s|%s\n' "${BANK_NAMES[$i]}" "${BANK_DESCS[$i]}" "${BANK_CMDS[$i]}" + local cmd="${BANK_CMDS[$i]}" + # Escape: every backslash → \\, every real newline → \n (keeps one record per physical line) + cmd="${cmd//\\/\\\\}" + cmd="${cmd//$'\n'/\\n}" + printf '%s|%s|%s\n' "${BANK_NAMES[$i]}" "${BANK_DESCS[$i]}" "$cmd" done } > "$tmp" chmod 600 "$tmp" diff --git a/tests/README.md b/tests/README.md index 47e3436..6fa6df3 100644 --- a/tests/README.md +++ b/tests/README.md @@ -57,4 +57,4 @@ silently. | `t-share-mountpoint.sh` | share-client `ask_mountpoint` UX: existing/new/declined/rejected paths, confirm gate, mkdir side effects, non-TTY stdin contract, static `n`→`t` guards | | `t-pos-media-yt.sh` | unified `pos media yt` suite: dispatcher + forwarder resolution, shared yt-lib helpers, yt-mp3/mp4/grab/subtitles flags, dry-run deps, `YT_OUT_DIR` seam, `GRAB_DEFAULT` config, negative controls (unsafe-URL no-expansion, `--lang en,ar` single arg, txt timestamp-stripping) | | `t-telegram-listener-singleton.sh` | Telegram listener single-instance guard: first `--run` acquires the flock, second `--run` fails fast with the exact message, lock auto-releases so the next start is clean, `--status` reports the lock state | -| `t-bank.sh` | Command Bank: bank-lib.sh unit tests (add/remove/update/find/get/list/count/valid/extract_params/substitute_params) + pos-bank CLI integration (help, list, add, show, run, remove, params, invalid name) | \ No newline at end of file +| `t-bank.sh` | Command Bank: bank-lib.sh unit tests (add/remove/update/find/get/list/count/valid/extract_params/substitute_params, multiline `\n` storage round-trip, literal-`\n` escape round-trip, v1 backward compat) + pos-bank CLI integration (help, list, add, show, run, remove, params, invalid name, multiline show/run) | \ No newline at end of file diff --git a/tests/t-bank.sh b/tests/t-bank.sh index fd2ca86..95e5a08 100755 --- a/tests/t-bank.sh +++ b/tests/t-bank.sh @@ -250,6 +250,83 @@ BANK ) && printf ' PASS bank_load skips comments and blank lines\n' \ || printf ' FAIL bank_load skips comments and blank lines\n' + # A19: multiline command storage round-trip — escapes \n, single record line + ( + BANK_FILE="$sandbox/a19.bank.env" + : > "$BANK_FILE" + source "$helper" + script="$(cat <<'SCRIPT' +#!/usr/bin/env bash +for tag in one two; do + out="$(printf '%s' "$tag" | tr 'a-z' 'A-Z')" + echo "tag=${tag} out=${out} done" +done +SCRIPT +)" + bank_add "ml" "Multiline demo" "$script" + bank_load + [ "${#BANK_NAMES[@]}" -eq 1 ] || { echo "FAIL A19: expected 1 entry, got ${#BANK_NAMES[@]}"; exit 1; } + [ "${BANK_CMDS[0]}" = "$script" ] || { echo "FAIL A19: command bytes differ"; exit 1; } + rec_lines="$(grep -c '^ml|' "$BANK_FILE" || true)" + [ "$rec_lines" -eq 1 ] || { echo "FAIL A19: expected 1 record line, got $rec_lines"; exit 1; } + [ "$(wc -l < "$BANK_FILE")" -eq 4 ] || { echo "FAIL A19: file has bogus physical lines"; exit 1; } + grep -q '\\n' "$BANK_FILE" || { echo "FAIL A19: missing \\n escape in file"; exit 1; } + echo "PASS A19: multiline round-trip" + ) && printf ' PASS multiline storage round-trip (one record line, \\n escapes)\n' \ + || printf ' FAIL multiline storage round-trip (one record line, \\n escapes)\n' + + # A20: literal \n (backslash-n) in command round-trips literally — NOT a real newline + ( + BANK_FILE="$sandbox/a20.bank.env" + : > "$BANK_FILE" + source "$helper" + esc="$(printf '%s' "printf 'a\\nb'")" + bank_add "esc" "Escapes" "$esc" + bank_load + [ "${BANK_CMDS[0]}" = "$esc" ] || { echo "FAIL A20: literal \\n not preserved [${BANK_CMDS[0]}]"; exit 1; } + case "${BANK_CMDS[0]}" in + *$'\n'*) echo "FAIL A20: decoded literal \\n into a real newline"; exit 1 ;; + esac + echo "PASS A20: literal \\n round-trip" + ) && printf ' PASS literal \\n command round-trips literally\n' \ + || printf ' FAIL literal \\n command round-trips literally\n' + + # A21: old-format file (no BANK_VERSION marker) keeps raw backslashes — no %b decode + ( + BANK_FILE="$sandbox/a21.bank.env" + cat > "$BANK_FILE" <<'BANK' +# Command Bank — managed by pos bank (do not hand-edit) +# Format: name|description|command +raw|Raw echo|echo 'a\b' +BANK + source "$helper" + bank_load + expected="$(printf '%s' "echo 'a\\b'")" + [ "${BANK_CMDS[0]}" = "$expected" ] || { echo "FAIL A21: raw backslash changed [${BANK_CMDS[0]}]"; exit 1; } + echo "PASS A21: old-format raw backslash" + ) && printf ' PASS old-format file keeps raw backslash (no decode)\n' \ + || printf ' FAIL old-format file keeps raw backslash (no decode)\n' + + # A22: old-format entry survives a re-save byte-identically (escape + decode round-trip) + ( + BANK_FILE="$sandbox/a22.bank.env" + cat > "$BANK_FILE" <<'BANK' +# Command Bank — managed by pos bank (do not hand-edit) +# Format: name|description|command +ts-google|Tailscale Google|tailscale status --json >/tmp/ts.json && grep -q '"ExitNodeStatus":null' /tmp/ts.json && tailscale set --exit-node=google || tailscale set --exit-node= +BANK + source "$helper" + bank_load + orig="${BANK_CMDS[0]}" + bank_add "new-cmd" "New entry" "echo ok" + bank_load + [ "${#BANK_NAMES[@]}" -eq 2 ] || { echo "FAIL A22: expected 2 entries, got ${#BANK_NAMES[@]}"; exit 1; } + [ "${BANK_CMDS[0]}" = "$orig" ] || { echo "FAIL A22: old entry changed after re-save"; exit 1; } + [ "${BANK_CMDS[1]}" = "echo ok" ] || { echo "FAIL A22: new entry missing"; exit 1; } + echo "PASS A22: old-format re-save round-trip" + ) && printf ' PASS old-format entry round-trips through re-save\n' \ + || printf ' FAIL old-format entry round-trips through re-save\n' + # ═══════════════════════════════════════════════════════════════ # Part B: pos-bank CLI integration tests # ═══════════════════════════════════════════════════════════════ @@ -359,4 +436,32 @@ BANK check_rc "pos bank run with params exits 0" 0 "$TR_RC" check_contains "pos bank run substitutes param" 'Running: echo hi "there"' "$TR_OUT" check_contains "pos bank run executes substituted command" "hi there" "$TR_OUT" + + # B15: pos bank add multiline + show — cmd_show retrieves the FULL script via arrays + # Negative control: bank_get + cut -f3 truncated the command at the first newline. + # (Brace-free script: {param} template detection would prompt on run in a non-TTY.) + : > "$sandbox/b15.bank.env" + ml_script="$(cat <<'SCRIPT' +#!/usr/bin/env bash +n=0 +while [ "$n" -lt 2 ]; do + n=$((n + 1)) + echo "round $n: $(printf 'ok')" +done +SCRIPT +)" + test_run env BANK_FILE="$sandbox/b15.bank.env" "$pos_bank" add "ml-demo" "Multiline demo" "$ml_script" + check_rc "pos bank add multiline exits 0" 0 "$TR_RC" + test_run env BANK_FILE="$sandbox/b15.bank.env" "$pos_bank" show "ml-demo" + check_rc "pos bank show multiline exits 0" 0 "$TR_RC" + check_contains "pos bank show prints script shebang" '#!/usr/bin/env bash' "$TR_OUT" + check_contains "pos bank show prints loop line" 'while [ "$n" -lt 2 ]; do' "$TR_OUT" + check_contains "pos bank show prints arithmetic line" 'n=$((n + 1))' "$TR_OUT" + check_contains "pos bank show prints substitution echo" 'echo "round $n: $(printf' "$TR_OUT" + + # B16: pos bank run multiline — executes the WHOLE script via eval of the full command + test_run env BANK_FILE="$sandbox/b15.bank.env" "$pos_bank" run "ml-demo" + check_rc "pos bank run multiline exits 0" 0 "$TR_RC" + check_contains "pos bank run multiline output line 1" "round 1: ok" "$TR_OUT" + check_contains "pos bank run multiline output line 2" "round 2: ok" "$TR_OUT" }