#!/usr/bin/env bash set -euo pipefail # POS: system bank — Persistent command bank for saving and running shell commands # POS_SUBCMDS: list add show run edit remove alias source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh" source "$(dirname "$0")/../lib/menu-lib.sh" 2>/dev/null || source "$(dirname "$0")/menu-lib.sh" source "$(dirname "$0")/../lib/bank-lib.sh" 2>/dev/null || source "$(dirname "$0")/bank-lib.sh" usage() { cat <<'EOF' Usage: pos system bank [subcommand] [args] Persistent command bank for saving and running shell commands. Subcommands: list List all saved commands add [desc] [cmd] Add a new command (interactive if args missing) show Show command details run [key=val ...] Run a command (interactive for missing params) edit Edit an existing command remove Remove a command alias [alias_name] Create/update a bash alias for a bank command alias list List bash aliases in ~/.bashrc alias remove Remove a bash alias Interactive menu: pos system bank (no args on a terminal) Examples: pos system bank add disk-info "Disk usage" "df -h && du -sh /mnt/12T" pos system bank run disk-info pos system bank run convert-video input=/path/to/video.mp4 quality=23 output=/path/out.mp4 EOF exit 0 } case "${1:-}" in -h|--help) usage ;; esac # ── Interactive command picker ─────────────────────────────────── _pick_command() { bank_load local count="${#BANK_NAMES[@]}" if [ "$count" -eq 0 ]; then warn "Command bank is empty — add one with: pos system bank add \"desc\" \"cmd\"" return 1 fi local -a items=() local i for ((i = 0; i < count; i++)); do if [ -n "${BANK_DESCS[$i]}" ]; then items+=("${BANK_NAMES[$i]} — ${BANK_DESCS[$i]}") else items+=("${BANK_NAMES[$i]}") fi done local choice choice="$(menu_pick "Pick command" "${items[@]}")" || return 1 echo "${BANK_NAMES[$((choice - 1))]}" } # ── cmd_list ───────────────────────────────────────────────────── cmd_list() { bank_load local count="${#BANK_NAMES[@]}" if [ "$count" -eq 0 ]; then log "Command bank is empty — add one with: pos system bank add \"desc\" \"cmd\"" return 0 fi echo echo "COMMAND BANK" echo local i for ((i = 0; i < count; i++)); do if [ -n "${BANK_DESCS[$i]}" ]; then printf ' %-25s %s\n' "${BANK_NAMES[$i]}" "${BANK_DESCS[$i]}" else printf ' %s\n' "${BANK_NAMES[$i]}" fi done echo } # ── cmd_add ────────────────────────────────────────────────────── cmd_add() { local name="" desc="" cmd="" case $# in 0) ;; 1) name="$1" ;; 2) name="$1"; desc="$2" ;; *) name="$1"; desc="$2"; cmd="$3" ;; esac if [ -z "$name" ]; then name="$(menu_ask_value "Name")" || return 0 fi if [ -z "$desc" ]; then desc="$(menu_ask_value "Description" "")" || return 0 fi if [ -z "$cmd" ]; then cmd="$(menu_read_value "Command")" || return 0 fi bank_valid_name "$name" || err "Invalid name: $name (use letters, numbers, hyphens, underscores)" [ -n "$cmd" ] || err "Command cannot be empty" bank_load local i for ((i = 0; i < ${#BANK_NAMES[@]}; i++)); do [[ "${BANK_NAMES[$i]}" == "$name" ]] && err "Command '$name' already exists (use 'pos system bank edit' to modify)" done bank_add "$name" "$desc" "$cmd" ok "Saved: $name" } # ── cmd_show ───────────────────────────────────────────────────── cmd_show() { [ -n "${1:-}" ] || err "Usage: pos system bank show " local name="$1" bank_load bank_find "$name" >/dev/null 2>&1 || err "Command not found: $name" local cmd_name cmd_desc cmd_cmd cmd_name="${BANK_NAMES[$BANK_IDX]}" cmd_desc="${BANK_DESCS[$BANK_IDX]}" cmd_cmd="${BANK_CMDS[$BANK_IDX]}" echo echo "Name: $cmd_name" echo echo "Description:" if [ -n "$cmd_desc" ]; then echo "$cmd_desc" else echo " (none)" fi echo echo "Command:" echo echo "$cmd_cmd" local params params="$(_extract_params "$cmd_cmd")" if [ -n "$params" ]; then echo echo "Parameters:" echo "$params" | while IFS= read -r p; do echo " $p" done fi echo } # ── cmd_run ────────────────────────────────────────────────────── cmd_run() { local name="" local -a cli_params=() if [ $# -eq 0 ]; then name="$(_pick_command)" || return 0 else name="$1"; shift while [ $# -gt 0 ]; do case "$1" in *=*) cli_params+=("$1") ;; *) err "Invalid parameter format: $1 (expected key=value)" ;; esac shift done fi bank_load bank_find "$name" >/dev/null 2>&1 || err "Command not found: $name" local cmd_name cmd_desc cmd_cmd 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")" local -a params=() if [ -n "$params_str" ]; then while IFS= read -r p; do params+=("$p") done <<< "$params_str" fi # Build parameter values from CLI args local -A param_values=() for p in "${cli_params[@]}"; do local key="${p%%=*}" val="${p#*=}" param_values["$key"]="$val" done # Interactively prompt for missing parameters local -a missing=() for p in "${params[@]}"; do [[ -z "${param_values[$p]+x}" ]] && missing+=("$p") done if [ ${#missing[@]} -gt 0 ]; then for p in "${missing[@]}"; do local val val="$(menu_ask_value "$p" "")" || return 0 param_values["$p"]="$val" done fi # Substitute parameters local final_cmd="$cmd_cmd" if [ ${#params[@]} -gt 0 ]; then local -a sub_args=() for p in "${params[@]}"; do sub_args+=("${p}=${param_values[$p]:-}") done final_cmd="$(_substitute_params "$cmd_cmd" "${sub_args[@]}")" fi echo log "Running: $final_cmd" echo eval "$final_cmd" return $? } # ── cmd_edit ───────────────────────────────────────────────────── cmd_edit() { local name="${1:-}" if [ -z "$name" ]; then name="$(_pick_command)" || return 0 fi bank_load bank_find "$name" >/dev/null 2>&1 || err "Command not found: $name" local old_name old_desc old_cmd old_name="${BANK_NAMES[$BANK_IDX]}" old_desc="${BANK_DESCS[$BANK_IDX]}" old_cmd="${BANK_CMDS[$BANK_IDX]}" echo log "Editing: $old_name" echo local new_name new_desc new_cmd new_name="$(menu_ask_value "New name" "$old_name")" || return 0 new_desc="$(menu_ask_value "New description" "$old_desc")" || return 0 new_cmd="$(menu_read_value "New command")" || return 0 [ -z "$new_name" ] && new_name="$old_name" [ -z "$new_desc" ] && new_desc="$old_desc" [ -z "$new_cmd" ] && new_cmd="$old_cmd" if [ "$new_name" != "$old_name" ]; then bank_valid_name "$new_name" || err "Invalid name: $new_name" local i found=0 for ((i = 0; i < ${#BANK_NAMES[@]}; i++)); do [[ "${BANK_NAMES[$i]}" == "$new_name" ]] && found=1 done [[ "$found" -eq 1 ]] && err "A command named '$new_name' already exists" bank_remove "$old_name" bank_add "$new_name" "$new_desc" "$new_cmd" else bank_update "$new_name" "$new_desc" "$new_cmd" fi ok "Updated: $new_name" } # ── cmd_remove ─────────────────────────────────────────────────── cmd_remove() { local name="${1:-}" if [ -z "$name" ]; then name="$(_pick_command)" || return 0 fi bank_load bank_find "$name" >/dev/null 2>&1 || err "Command not found: $name" if [ -t 0 ]; then confirm "Remove '$name'?" n || { log "Cancelled"; return 0; } fi bank_remove "$name" # Drop bash aliases pointing at the removed command (best-effort upkeep; # a malformed block is a warning, never a remove blocker). if [ -f "$BASH_RC_FILE" ] && grep -qF "$ALIAS_START" "$BASH_RC_FILE"; then if alias_block_parse; then local -a keep=() i local removed_alias=0 for ((i = 0; i < ${#ALIAS_NAMES[@]}; i++)); do if [[ "${ALIAS_TARGETS[${ALIAS_NAMES[$i]}]:-}" == "$name" ]]; then removed_alias=1 else keep+=("${ALIAS_NAMES[$i]}") fi done if [ "$removed_alias" -eq 1 ]; then ALIAS_NAMES=("${keep[@]}") alias_block_write log "Removed alias pointing to '$name' from $BASH_RC_FILE" fi else local rc=$? [ "$rc" -eq 2 ] && warn "Alias block in $BASH_RC_FILE is malformed — left untouched" fi fi ok "Removed: $name" } # ── Alias management ───────────────────────────────────────────── # Bash aliases for bank commands live in a single managed block inside # $BASH_RC_FILE (~/.bashrc by default), so they survive rebuilds and are easy # to see/undo by hand. Every line has the exact shape: # alias ='pos system bank run ' # The block is written atomically (tmp + mv, preserving file permissions). ALIAS_START='# >>> pos bank aliases (managed by pos system bank — do not hand-edit) <<<' ALIAS_END='# <<< pos bank aliases (managed by pos system bank) <<<' BASH_RC_FILE="${BASH_RC_FILE:-$HOME/.bashrc}" # Valid bash alias names we manage: letters/digits/_/- (same rule as bank names). alias_valid_name() { [[ "$1" =~ ^[a-zA-Z][a-zA-Z0-9_-]*$ ]] } # Parse the managed block from $BASH_RC_FILE into: # ALIAS_NAMES — ordered array of alias names (file order, unique) # ALIAS_TARGETS — assoc: alias_name → bank command name # Returns: 0 = healthy block present, 1 = no block, 2 = malformed block. alias_block_parse() { declare -g -a ALIAS_NAMES declare -g -A ALIAS_TARGETS ALIAS_NAMES=() ALIAS_TARGETS=() [ -f "$BASH_RC_FILE" ] || return 1 local line in_block=0 starts=0 ends=0 rc=0 while IFS= read -r line; do if [ "$in_block" -eq 0 ]; then if [[ "$line" == "$ALIAS_START" ]]; then in_block=1 starts=$((starts + 1)) [ "$starts" -gt 1 ] && rc=2 elif [[ "$line" == "$ALIAS_END" ]]; then rc=2 fi elif [[ "$line" == "$ALIAS_END" ]]; then in_block=0 ends=$((ends + 1)) elif [[ "$line" =~ ^alias[[:space:]]+([a-zA-Z][a-zA-Z0-9_-]*)= ]]; then # NOTE: no `'` inside the =~ regex (bash treats quotes there as # quoting operators and would literalize the ( ) groups) — the # quoted payload is matched with a glob `case` instead. local a="${BASH_REMATCH[1]}" local payload="${line#*=}" case "$payload" in "'pos system bank run "[a-zA-Z][a-zA-Z0-9_-]*"'") local t="${payload%\'}" t="${t#\'pos system bank run }" if [[ " ${ALIAS_NAMES[*]:-} " == *" $a "* ]]; then rc=2 continue fi ALIAS_NAMES+=("$a") ALIAS_TARGETS["$a"]="$t" ;; *) rc=2 ;; esac else rc=2 fi done < "$BASH_RC_FILE" [ "$in_block" -eq 1 ] && rc=2 [ "$rc" -eq 0 ] && [ "$starts" -eq 0 ] && return 1 return "$rc" } # Require a healthy managed block (alias commands err on missing/malformed). alias_block_require() { local rc if alias_block_parse; then return 0 else rc=$? fi if [ "$rc" -eq 1 ]; then err "No alias block in $BASH_RC_FILE — add one with: pos system bank alias " fi err "Alias block in $BASH_RC_FILE is malformed (markers or lines were hand-edited) — fix it manually, then retry" } # Scan for an alias NAME defined OUTSIDE the managed block (a collision we # refuse to overwrite). Prints the offending line; rc 0 = found, 1 = clean. alias_collision() { local name="$1" [ -f "$BASH_RC_FILE" ] || return 1 local line in_block=0 while IFS= read -r line; do if [ "$in_block" -eq 0 ] && [[ "$line" == "$ALIAS_START" ]]; then in_block=1 continue fi if [ "$in_block" -eq 1 ] && [[ "$line" == "$ALIAS_END" ]]; then in_block=0 continue fi if [ "$in_block" -eq 0 ] && [[ "$line" =~ ^[[:space:]]*alias[[:space:]]+$name= ]]; then printf '%s\n' "$line" return 0 fi done < "$BASH_RC_FILE" return 1 } # Rewrite $BASH_RC_FILE from ALIAS_NAMES + ALIAS_TARGETS. Callers guarantee the # file is absent or has at most one healthy block. Removes the block entirely # when ALIAS_NAMES is empty; appends a fresh block when none exists yet. alias_block_write() { local content="" start_line=0 end_line=0 n=0 line local pre="" post="" dir perms tmp new_content="" if [ -f "$BASH_RC_FILE" ]; then content="$(< "$BASH_RC_FILE")" perms="$(stat -c %a "$BASH_RC_FILE" 2>/dev/null || printf '600')" while IFS= read -r line; do n=$((n + 1)) if [ "$start_line" -eq 0 ] && [[ "$line" == "$ALIAS_START" ]]; then start_line=$n elif [ "$start_line" -gt 0 ] && [ "$end_line" -eq 0 ] && [[ "$line" == "$ALIAS_END" ]]; then end_line=$n fi done <<< "$content" if [ "$start_line" -gt 0 ] && [ "$end_line" -eq 0 ]; then err "Alias block in $BASH_RC_FILE has no end marker — fix it manually, then retry" fi else perms=600 fi # No block and nothing to manage → never touch the file (nor create it). if [ "$start_line" -eq 0 ] && [ "${#ALIAS_NAMES[@]}" -eq 0 ]; then return 0 fi if [ "$start_line" -gt 0 ]; then # NOTE: sed "1,0p" is NOT empty in GNU sed (0 is a last-line sentinel, # it prints line 1) — guard the ranges so edge blocks rewrite cleanly. if [ "$start_line" -gt 1 ]; then pre="$(printf '%s\n' "$content" | sed -n "1,$((start_line - 1))p")" fi if [ "$end_line" -lt "$n" ]; then post="$(printf '%s\n' "$content" | sed -n "$((end_line + 1)),\$p")" fi else pre="$(printf '%s\n' "$content")" fi if [ -n "$pre" ]; then new_content+="$pre" case "$new_content" in *$'\n') ;; *) new_content+=$'\n' ;; esac fi if [ "${#ALIAS_NAMES[@]}" -gt 0 ]; then new_content+="$ALIAS_START"$'\n' local i for ((i = 0; i < ${#ALIAS_NAMES[@]}; i++)); do new_content+="alias ${ALIAS_NAMES[$i]}='pos system bank run ${ALIAS_TARGETS[${ALIAS_NAMES[$i]}]:-}'"$'\n' done new_content+="$ALIAS_END"$'\n' fi if [ -n "$post" ]; then new_content+="$post" case "$new_content" in *$'\n') ;; *) new_content+=$'\n' ;; esac fi dir="$(dirname "$BASH_RC_FILE")" mkdir -p "$dir" || err "Cannot create directory: $dir" tmp="$(mktemp "${dir}/.pos-bank-aliases.XXXXXX")" || err "Cannot create temp file in $dir" printf '%s' "$new_content" > "$tmp" chmod "$perms" "$tmp" mv "$tmp" "$BASH_RC_FILE" } # ── cmd_alias ──────────────────────────────────────────────────── cmd_alias() { case "${1:-}" in list) shift; cmd_alias_list "$@" ;; remove) shift; cmd_alias_remove "$@" ;; "") err "Usage: pos system bank alias [alias_name] | list | remove " ;; *) cmd_alias_add "$@" ;; esac } cmd_alias_add() { local name="${1:-}" alias_name="${2:-$1}" [ -n "$name" ] || err "Usage: pos system bank alias [alias_name]" bank_valid_name "$name" || err "Invalid command name: $name" alias_valid_name "$alias_name" || err "Invalid alias name: $alias_name (use letters, numbers, hyphens, underscores)" bank_load bank_find "$name" >/dev/null 2>&1 || err "Command not found: $name" local rc if alias_block_parse; then rc=0 else rc=$? fi [ "$rc" -eq 2 ] && err "Alias block in $BASH_RC_FILE is malformed (hand-edited?) — fix it manually, then retry" if [ -f "$BASH_RC_FILE" ] && hit="$(alias_collision "$alias_name")"; then err "Alias '$alias_name' is already defined outside the managed block in $BASH_RC_FILE: $hit" fi if command -v "$alias_name" >/dev/null 2>&1; then warn "Note: '$alias_name' is also a command on PATH — the alias will shadow it in interactive shells" fi local found=0 i for ((i = 0; i < ${#ALIAS_NAMES[@]}; i++)); do [[ "${ALIAS_NAMES[$i]}" == "$alias_name" ]] && found=1 done [ "$found" -eq 0 ] && ALIAS_NAMES+=("$alias_name") ALIAS_TARGETS["$alias_name"]="$name" alias_block_write ok "Alias '$alias_name' → pos system bank run $name (in $BASH_RC_FILE)" log "Run: source $BASH_RC_FILE (or open a new shell)" } cmd_alias_list() { alias_block_require if [ "${#ALIAS_NAMES[@]}" -eq 0 ]; then log "No aliases in $BASH_RC_FILE — add one with: pos system bank alias " return 0 fi echo echo "BANK ALIASES (in $BASH_RC_FILE)" echo local i for ((i = 0; i < ${#ALIAS_NAMES[@]}; i++)); do printf ' %-25s → pos system bank run %s\n' "${ALIAS_NAMES[$i]}" "${ALIAS_TARGETS[${ALIAS_NAMES[$i]}]:-}" done echo } # ── Interactive alias picker ───────────────────────────────────── _pick_alias() { local rc if alias_block_parse; then rc=0 else rc=$? fi [ "$rc" -eq 2 ] && err "Alias block in $BASH_RC_FILE is malformed — fix it manually" [ "$rc" -eq 1 ] && err "No alias block in $BASH_RC_FILE — add one with: pos system bank alias " if [ "${#ALIAS_NAMES[@]}" -eq 0 ]; then warn "No aliases yet — add one first" return 1 fi local -a items=() local i for ((i = 0; i < ${#ALIAS_NAMES[@]}; i++)); do items+=("${ALIAS_NAMES[$i]} → pos system bank run ${ALIAS_TARGETS[${ALIAS_NAMES[$i]}]:-}") done local choice choice="$(menu_pick "Pick alias" "${items[@]}")" || return 1 echo "${ALIAS_NAMES[$((choice - 1))]}" } cmd_alias_remove() { local alias_name="${1:-}" if [ -z "$alias_name" ]; then if [ -t 0 ]; then alias_name="$(_pick_alias)" || return 0 else err "Usage: pos system bank alias remove " fi fi alias_block_require local found=0 i for ((i = 0; i < ${#ALIAS_NAMES[@]}; i++)); do [[ "${ALIAS_NAMES[$i]}" == "$alias_name" ]] && found=1 done [ "$found" -eq 1 ] || err "Alias '$alias_name' not found (see: pos system bank alias list)" if [ -t 0 ]; then confirm "Remove alias '$alias_name'?" n || { log "Cancelled"; return 0; } fi local -a keep=() for ((i = 0; i < ${#ALIAS_NAMES[@]}; i++)); do [[ "${ALIAS_NAMES[$i]}" != "$alias_name" ]] && keep+=("${ALIAS_NAMES[$i]}") done ALIAS_NAMES=("${keep[@]}") unset "ALIAS_TARGETS[$alias_name]" alias_block_write ok "Removed alias '$alias_name' from $BASH_RC_FILE" log "Run: source $BASH_RC_FILE (or open a new shell)" } alias_menu() { while true; do local choice choice="$(menu_run "Bank Aliases" \ "List aliases" \ "Add alias" \ "Remove alias")" || return 0 case "$choice" in 1) cmd_alias_list ;; 2) local name alias_name name="$(_pick_command)" || return 0 alias_name="$(menu_ask_value "Alias name" "$name")" || return 0 [ -z "$alias_name" ] && alias_name="$name" cmd_alias_add "$name" "$alias_name" ;; 3) local alias_name alias_name="$(_pick_alias)" || return 0 cmd_alias_remove "$alias_name" ;; esac done } # ── Interactive menu ───────────────────────────────────────────── run_menu() { menu_guard || exit 1 while true; do local choice choice="$(menu_run "Command Bank" \ "List commands" \ "Add command" \ "Run command" \ "Edit command" \ "Remove command" \ "Manage aliases")" || return 0 case "$choice" in 1) cmd_list ;; 2) cmd_add ;; 3) cmd_run ;; 4) cmd_edit ;; 5) cmd_remove ;; 6) alias_menu ;; esac done } # ── Dispatch ───────────────────────────────────────────────────── if [ "${1:-}" = "menu" ]; then run_menu exit 0 fi for arg in "$@"; do case "$arg" in -h|--help) usage ;; esac done if [ $# -eq 0 ] && [ -t 0 ]; then run_menu exit 0 fi case "${1:-}" in list) shift; cmd_list "$@" ;; add) shift; cmd_add "$@" ;; show) shift; cmd_show "$@" ;; run) shift; cmd_run "$@" ;; edit) shift; cmd_edit "$@" ;; remove) shift; cmd_remove "$@" ;; alias) shift; cmd_alias "$@" ;; "") usage ;; *) err "Unknown subcommand: $1 (see --help)" ;; esac