KeiSeiKit-1.0/install/lib-menu.sh
Parfii-bot 03d1dc7362 refactor(v0.16): split install.sh monolith (1238 LOC) into 17 cubes
Constructor Pattern (RULE ZERO). Zero behaviour change, zero flag
drift — all original CLI flags preserved verbatim.

Before: install.sh — 1238 LOC monolith
After:  install.sh — 138 LOC dispatcher (sources libs in order)
        install/lib-*.sh — 16 cubes, max 183 LOC (lib-menu)

Cubes:
  lib-log       21 LOC — logging primitives
  lib-backup    63 LOC — rollback trap + BACKUP_PAIRS
  lib-profile  115 LOC — MANIFEST.toml profile resolution
  lib-args      92 LOC — CLI parsing + --help heredoc
  lib-menu     183 LOC — whiptail/dialog/plain-text interactive picker
  lib-plan     150 LOC — dry-run --no-execute output
  lib-prereqs   91 LOC — hard + soft dependency checks
  lib-primitives 131 LOC — primitive copy + MANIFEST drive
  lib-rust     114 LOC — cargo workspace build + pre-built support
  lib-scaffold 144 LOC — agent/skill/block scaffolding
  lib-bridges   31 LOC — project-bridge install
  lib-hooks    104 LOC — settings.json jq merge
  lib-agents    77 LOC — assembled agent output
  lib-skills    23 LOC — skill copy
  lib-wizard    20 LOC — sleep-setup wizard invocation
  lib-summary   59 LOC — post-install summary

Invariants preserved:
  - macOS bash 3.2 compat (no associative arrays, no [[ ]], no ${,,})
  - rollback trap wired via setup_backup_trap early in dispatcher
  - jq-merge behaviour verbatim in lib-hooks
  - scoped Cargo.toml regeneration in lib-rust

Function LOC limits: largest non-heredoc fn 22 LOC (check_soft_prereqs).
Three functions kept >30 LOC because heredoc-dominated (print_help,
print_summary, profile_members); splitting would fragment logical unit.

62 unique function names across cubes, zero duplicates (grep-verified).

bash -n passes on all 17 files. Runtime smoke test deferred to user's
shell (bash-readonly sandbox constraint).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:09:35 +08:00

183 lines
6.6 KiB
Bash

# shellcheck shell=bash
# lib-menu.sh — interactive menu (option C hybrid).
#
# Hierarchy: whiptail > dialog > plain-text bash-select. Stdout contract:
# - one-line output = profile name OR comma-separated custom primitive list
# - empty stdout + exit 1 = user cancelled
# Menu is ONLY triggered from the top-level flow: never from --add/--remove/--list.
#
# Requires: all_primitive_names, primitive_field from lib-profile.sh.
# Requires: err from lib-log.sh.
# Reads globals: PROFILE, ADD_LIST, REMOVE_NAME, LIST_MODE (set by install.sh).
# menu_should_skip — return 0 if menu should be skipped, 1 if it should run.
# Skip reasons: any selection flag was passed, or stdin/stdout is not a TTY.
menu_should_skip() {
[ -n "$PROFILE" ] && return 0
[ -n "$ADD_LIST" ] && return 0
[ -n "$REMOVE_NAME" ] && return 0
[ "$LIST_MODE" = "1" ] && return 0
[ ! -t 0 ] && return 0
[ ! -t 1 ] && return 0
return 1
}
# whiptail/dialog radiolist → profile name. Exits 1 on cancel.
menu_whiptail_profile() {
local tool="$1"
"$tool" --title "KeiSeiKit Installer" --radiolist \
"Choose install profile (SPACE to select, ENTER to confirm):" 22 78 8 \
"minimal" "agents + hooks + skills + bridges (~5s)" ON \
"core" "+ tomd (~5s)" OFF \
"frontend" "+ 8 site tools (~60s, 80 MB)" OFF \
"ops" "+ 8 infra tools (~90s, 50 MB)" OFF \
"dev" "+ 9 dev tools (~60s, 40 MB)" OFF \
"mcp" "+ 10 LBM-port MCP tools (~90s, 50 MB)" OFF \
"full" "all 36 primitives (~5 min, 200 MB)" OFF \
"custom" "pick individual primitives" OFF \
3>&1 1>&2 2>&3
}
# whiptail/dialog checklist → comma-separated primitive names. Exits 1 on cancel.
menu_whiptail_custom() {
local tool="$1"
local args=() name desc
while IFS= read -r name; do
[ -z "$name" ] && continue
desc="$(primitive_field "$name" desc 2>/dev/null || echo '')"
# truncate long descs so whiptail doesn't wrap awkwardly
desc="${desc:0:48}"
args+=("$name" "$desc" "OFF")
done < <(all_primitive_names)
local picked
picked="$("$tool" --title "Custom — pick primitives" --checklist \
"SPACE to toggle, ENTER to confirm:" 24 78 16 \
"${args[@]}" 3>&1 1>&2 2>&3)" || return 1
# whiptail emits quoted names separated by spaces; normalize to csv
echo "$picked" | tr -d '"' | tr ' ' ',' | sed 's/^,//;s/,$//'
}
# plain-text profile picker → profile name. Exits 1 on cancel.
menu_plain_profile() {
echo "================================" >&2
echo " KeiSeiKit Installer" >&2
echo "================================" >&2
echo >&2
echo "Choose install profile:" >&2
echo >&2
echo " 1) minimal — agents + hooks + skills + bridges only (~5s)" >&2
echo " 2) core — + tomd (~5s)" >&2
echo " 3) frontend — + 8 site tools (~60s, 80 MB)" >&2
echo " 4) ops — + 8 infra tools (~90s, 50 MB)" >&2
echo " 5) dev — + 9 dev tools (~60s, 40 MB)" >&2
echo " 6) mcp — + 10 LBM-port MCP tools (~90s, 50 MB)" >&2
echo " 7) full — all 36 primitives (~5 min, 200 MB)" >&2
echo " 8) custom — pick individual primitives" >&2
echo >&2
local reply
printf 'Enter choice [1-8] (default 1): ' >&2
read -r reply || return 1
case "${reply:-1}" in
1) echo minimal ;;
2) echo core ;;
3) echo frontend ;;
4) echo ops ;;
5) echo dev ;;
6) echo mcp ;;
7) echo full ;;
8) echo custom ;;
*) err "invalid choice: $reply"; return 1 ;;
esac
}
# Print the numbered primitive list to stderr (helper for plain custom picker).
_print_primitive_list() {
local -a names=("$@")
local i desc
echo >&2
echo "Select primitives (space-separated numbers, 'a' for all, 'n' for none):" >&2
echo >&2
for (( i=0; i<${#names[@]}; i++ )); do
desc="$(primitive_field "${names[$i]}" desc 2>/dev/null || echo '')"
printf " %2d) [ ] %-20s — %s\n" "$((i+1))" "${names[$i]}" "$desc" >&2
done
echo >&2
}
# plain-text custom picker → comma-separated primitive names.
menu_plain_custom() {
local -a names=() picked=()
local name reply tok
while IFS= read -r name; do
[ -z "$name" ] && continue
names+=("$name")
done < <(all_primitive_names)
_print_primitive_list "${names[@]}"
printf 'Selection: ' >&2
read -r reply || return 1
case "$reply" in
a|A|all) picked=("${names[@]}") ;;
n|N|none|'') picked=() ;;
*)
for tok in $reply; do
[[ "$tok" =~ ^[0-9]+$ ]] && (( tok >= 1 && tok <= ${#names[@]} )) \
&& picked+=("${names[$((tok-1))]}")
done
;;
esac
local IFS=,; echo "${picked[*]}"
}
# Run the menu and parse its output into PROFILE / CUSTOM_PRIMS globals.
# Returns 0 on success (incl. menu_should_skip), 1 on user cancel.
run_menu_if_needed() {
CUSTOM_PRIMS=""
CONFIRM_TOTAL=0
CONFIRM_SECS=0
CONFIRM_MB=0
menu_should_skip && return 0
[ -f "$MANIFEST" ] || { err "MANIFEST.toml missing: $MANIFEST"; exit 2; }
local menu_out
menu_out="$(show_interactive_menu)" || { say "menu cancelled — aborting"; return 1; }
if [ -z "$menu_out" ]; then
say "no selection — aborting"
return 1
fi
if echo "$menu_out" | grep -q ','; then
CUSTOM_PRIMS="$menu_out"
PROFILE="custom"
elif echo "$menu_out" | grep -qE '^(minimal|core|frontend|ops|dev|mcp|full)$'; then
PROFILE="$menu_out"
else
# Single name from custom-with-one-item — treat as CUSTOM_PRIMS
CUSTOM_PRIMS="$menu_out"
PROFILE="custom"
fi
return 0
}
# show_interactive_menu — master dispatcher. Echoes profile name OR csv list.
show_interactive_menu() {
local tool=""
if command -v whiptail >/dev/null 2>&1; then
tool="whiptail"
elif command -v dialog >/dev/null 2>&1; then
tool="dialog"
fi
local choice
if [ -n "$tool" ]; then
choice="$(menu_whiptail_profile "$tool")" || return 1
if [ "$choice" = "custom" ]; then
menu_whiptail_custom "$tool" || return 1
else
echo "$choice"
fi
else
choice="$(menu_plain_profile)" || return 1
if [ "$choice" = "custom" ]; then
menu_plain_custom
else
echo "$choice"
fi
fi
}