feat(install): interactive menu (whiptail/dialog/plain) + confirm screen + --yes/--no-execute

- TUI via whiptail (preferred) or dialog; plain-text fallback with zero deps
- Install Plan confirm screen: primitives, soft-deps status (✓/✗), estimates
- Skip menu on --profile/--add/--remove/--list or non-TTY (CI-safe)
- --yes skips confirm; --no-execute dry-run
- install.sh 844 → 1195 LOC, 10 new functions all <30 LOC
- README +8 LOC Interactive install section
This commit is contained in:
Parfii-bot 2026-04-21 23:11:58 +08:00
parent 9bcbf069d5
commit b1ce0609ee
2 changed files with 364 additions and 4 deletions

View file

@ -43,6 +43,15 @@ cd KeiSeiKit
After install, the only remaining step is merging `settings-snippet.json` into your `~/.claude/settings.json` to activate the hooks. You can do this automatically with `./install.sh --activate-hooks` or answer `y` at the end-of-install TTY prompt.
### Interactive install
Run `./install.sh` with no profile flag on a TTY and you get a menu:
- `whiptail` or `dialog` detected → curses-style TUI (radiolist for profile, checklist for custom)
- neither available → plain-text numbered picker (`1-7` + a `custom` option)
After the profile is chosen, an **Install Plan** screen summarizes what will be copied, which soft-deps are present (`jq`, `pandoc`, `playwright`, `cargo`, `hcloud`, `vultr-cli`, `yq`, `sqlite3`, `curl`), and the rough time + disk footprint — then asks `Proceed? [Y/n]`. Pass `--yes` to skip the confirm screen (the menu still runs). Pass `--no-execute` to parse menu + confirm and exit without copying anything (useful for dry-run). The menu is **skipped automatically** when any selection flag is passed (`--profile`, `--add`, `--remove`, `--list`) or when stdin/stdout is not a TTY (CI runs default to `minimal` exactly as before).
## Install profiles
By default `./install.sh` is **minimal** — agents + hooks + skills + bridges, no primitives. Fastest (~5s) and zero Rust compile for primitives. You opt into primitives via `--profile=<name>` or one-at-a-time via `--add=<name>`.

View file

@ -3,13 +3,15 @@
# Idempotent: safe to re-run. Never overwrites settings.json or existing user manifests.
#
# Usage:
# ./install.sh # profile=minimal (agents + hooks + skills + bridges, NO primitives)
# ./install.sh --profile=<name> # minimal|core|frontend|ops|dev|full
# ./install.sh # interactive menu on TTY; profile=minimal on non-TTY
# ./install.sh --profile=<name> # minimal|core|frontend|ops|dev|full (skips menu)
# ./install.sh --add=<name>[,<name>] # install one or more primitives on top of current state
# ./install.sh --remove=<name> # remove a single primitive
# ./install.sh --list # list installed primitives (name | kind | desc | path)
# ./install.sh --with-bridges # also render cross-tool bridges into $PWD
# ./install.sh --activate-hooks # jq-merge settings-snippet.json into ~/.claude/settings.json
# ./install.sh --yes # skip confirm screen after menu (automation)
# ./install.sh --no-execute # parse menu+confirm, print plan, exit (testing)
set -euo pipefail
@ -28,6 +30,8 @@ PROFILE=""
ADD_LIST=""
REMOVE_NAME=""
LIST_MODE=0
ASSUME_YES=0
NO_EXECUTE=0
for arg in "$@"; do
case "$arg" in
@ -37,6 +41,8 @@ for arg in "$@"; do
--add=*) ADD_LIST="${arg#--add=}" ;;
--remove=*) REMOVE_NAME="${arg#--remove=}" ;;
--list) LIST_MODE=1 ;;
--yes|-y) ASSUME_YES=1 ;;
--no-execute) NO_EXECUTE=1 ;;
--help|-h)
cat <<EOF
Usage: ./install.sh [flags]
@ -69,6 +75,14 @@ Usage: ./install.sh [flags]
non-interactively. Without this flag, a TTY prompt asks
at the end; non-TTY runs print manual instructions.
--yes, -y skip the interactive confirm screen after the menu
(for automation). If no --profile was given the menu
still runs; --yes only auto-accepts the Install Plan.
--no-execute run flag parsing + menu + confirm, print the
resolved plan, then exit before copying/building
anything. Useful for dry-run / testing.
--help, -h this help.
EOF
exit 0
@ -257,6 +271,296 @@ all_primitive_names() {
' "$MANIFEST"
}
# --- interactive menu (option C hybrid) ------------------------------------
# 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.
# Per-primitive rough estimates. Used by estimate_install + menu descriptions.
# Shell primitives are ~1s / 5KB; rust primitives vary by dep weight.
# Hardcoded here (not in MANIFEST) to keep manifest declarative + UI hints local.
primitive_time_secs() {
local name="$1" kind
kind="$(primitive_field "$name" kind 2>/dev/null || true)"
case "$kind" in
shell) echo 1 ;;
rust)
case "$name" in
mock-render|kei-migrate|kei-ledger) echo 20 ;;
kei-changelog|firewall-diff) echo 15 ;;
visual-diff|tokens-sync|ssh-check) echo 5 ;;
*) echo 10 ;;
esac
;;
*) echo 0 ;;
esac
}
primitive_disk_kb() {
local name="$1" kind
kind="$(primitive_field "$name" kind 2>/dev/null || true)"
case "$kind" in
shell) echo 5 ;;
rust)
case "$name" in
mock-render|kei-migrate|kei-ledger) echo 30000 ;;
kei-changelog|firewall-diff) echo 10000 ;;
visual-diff|tokens-sync|ssh-check) echo 5000 ;;
*) echo 8000 ;;
esac
;;
*) echo 0 ;;
esac
}
# menu_should_skip — return 0 if menu should be skipped, 1 if it should run.
# Skip reasons: any selection flag was passed, or stdin 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 radiolist → profile name. Exits 1 on cancel.
menu_whiptail_profile() {
local tool="$1" # whiptail or dialog
"$tool" --title "KeiSeiKit Installer" --radiolist \
"Choose install profile (SPACE to select, ENTER to confirm):" 20 78 7 \
"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" "+ 4 dev tools (~60s, 40 MB)" OFF \
"full" "all 21 primitives (~5 min, 200 MB)" OFF \
"custom" "pick individual primitives" OFF \
3>&1 1>&2 2>&3
}
# whiptail 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 — + 4 dev tools (~60s, 40 MB)" >&2
echo " 6) full — all 21 primitives (~5 min, 200 MB)" >&2
echo " 7) custom — pick individual primitives" >&2
echo >&2
local reply
printf 'Enter choice [1-7] (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 full ;;
7) 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[*]}"
}
# 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
}
# --- install plan helpers --------------------------------------------------
# estimate_install — reads newline-separated primitive names from stdin,
# prints "time_secs disk_kb" to stdout.
estimate_install() {
local total_secs=0 total_kb=0 name s d
while IFS= read -r name; do
[ -z "$name" ] && continue
s="$(primitive_time_secs "$name")"
d="$(primitive_disk_kb "$name")"
total_secs=$(( total_secs + s ))
total_kb=$(( total_kb + d ))
done
echo "$total_secs $total_kb"
}
# Consumers-of-tool — list primitives (from $2..$N) whose deps mention $1.
_consumers_of() {
local tool="$1"; shift
local n deps_raw out=""
for n in "$@"; do
deps_raw="$(primitive_field "$n" deps 2>/dev/null || true)"
echo "$deps_raw" | grep -qiE "(^|[^a-zA-Z])${tool}([^a-zA-Z]|$)" \
&& out="${out}${n},"
done
echo "${out%,}"
}
# check_soft_deps — reads newline-separated primitive names from stdin,
# prints one OK/MISS per unique soft-dep tool used by any listed primitive.
check_soft_deps() {
local names_nl
names_nl="$(cat)"
[ -z "$names_nl" ] && return 0
local -a tools=(jq pandoc playwright npx cargo hcloud vultr-cli yq sqlite3 curl)
local -a names_arr=()
local n tool consumers printed_header=0
while IFS= read -r n; do [ -n "$n" ] && names_arr+=("$n"); done <<< "$names_nl"
for tool in "${tools[@]}"; do
consumers="$(_consumers_of "$tool" "${names_arr[@]}")"
[ -z "$consumers" ] && continue
[ "$printed_header" = "0" ] && echo "Soft-dep status:" && printed_header=1
if command -v "$tool" >/dev/null 2>&1; then
echo " [OK] $tool installed"
else
echo " [MISS] $tool missing (needed for: $consumers)"
fi
done
}
# print_plan_body — prints the "Install Plan" block for the given label + names.
# Args: $1 = label, stdin = newline-separated primitive names.
# Per-primitive row (helper for print_plan_body). Stdin: newline names.
_print_primitive_rows() {
local name kind extra
while IFS= read -r name; do
[ -z "$name" ] && continue
kind="$(primitive_field "$name" kind 2>/dev/null || echo '?')"
extra="$(primitive_time_secs "$name")s, $(( $(primitive_disk_kb "$name") / 1024 )) MB"
printf ' + %-22s (%s, ~%s)\n' "$name" "$kind" "$extra"
done
}
print_plan_body() {
local profile_label="$1"
local names total est_secs est_kb est_mb
names="$(cat)"
total="$(printf '%s\n' "$names" | grep -c . || true)"
read -r est_secs est_kb <<< "$(printf '%s\n' "$names" | estimate_install)"
est_mb=$(( est_kb / 1024 ))
echo
echo "================================"
echo " Install Plan"
echo "================================"
echo
echo "Profile: $profile_label"
echo "Primitives: ${total:-0} to add"
[ "${total:-0}" -gt 0 ] && printf '%s\n' "$names" | _print_primitive_rows
echo
printf '%s\n' "$names" | check_soft_deps || true
echo
printf 'Estimated time: ~%ss\n' "$est_secs"
printf 'Estimated disk: ~%s MB\n' "$est_mb"
echo
CONFIRM_TOTAL="$total"; CONFIRM_SECS="$est_secs"; CONFIRM_MB="$est_mb"
}
# show_confirm_screen — prints plan body, then asks y/N (or whiptail --yesno).
# Stdin: newline-separated primitive names. Returns 0=confirmed, 1=declined.
show_confirm_screen() {
local profile_label="$1"
print_plan_body "$profile_label"
[ "$ASSUME_YES" = "1" ] && { echo "(--yes: auto-confirming)"; return 0; }
[ ! -t 0 ] && { echo "(non-TTY: auto-confirming)"; return 0; }
if command -v whiptail >/dev/null 2>&1; then
whiptail --yesno "Install ${CONFIRM_TOTAL:-0} primitive(s) for profile '$profile_label'?\n\nTime: ~${CONFIRM_SECS}s, disk: ~${CONFIRM_MB} MB" 14 70
return $?
fi
local reply
printf 'Proceed? [Y/n]: '
read -r reply || return 1
case "${reply:-Y}" in
y|Y|yes|YES|'') return 0 ;;
*) return 1 ;;
esac
}
# --- .installed state helpers ---------------------------------------------
read_installed() {
[ -f "$INSTALLED_FILE" ] && cat "$INSTALLED_FILE" || true
@ -539,11 +843,38 @@ if [ -n "$ADD_LIST" ] || [ -n "$REMOVE_NAME" ]; then
exit 0
fi
# --- interactive menu (option C hybrid) -----------------------------------
# Runs ONLY when: no selection flag passed AND stdin+stdout are TTY AND
# --list / --add / --remove short-circuits above did NOT fire. Sets either
# PROFILE (for a standard profile choice) or CUSTOM_PRIMS (comma-list).
CUSTOM_PRIMS=""
CONFIRM_TOTAL=0
CONFIRM_SECS=0
CONFIRM_MB=0
if ! menu_should_skip; then
[ -f "$MANIFEST" ] || { err "MANIFEST.toml missing: $MANIFEST"; exit 2; }
menu_out="$(show_interactive_menu)" || { say "menu cancelled — aborting"; exit 1; }
if [ -z "$menu_out" ]; then
say "no selection — aborting"
exit 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|full)$'; then
PROFILE="$menu_out"
else
# Single name from custom-with-one-item — treat as CUSTOM_PRIMS
CUSTOM_PRIMS="$menu_out"
PROFILE="custom"
fi
fi
# --- resolve profile ------------------------------------------------------
# Default profile is minimal.
PROFILE="${PROFILE:-minimal}"
case "$PROFILE" in
minimal|core|frontend|ops|dev|full) ;;
minimal|core|frontend|ops|dev|full|custom) ;;
*)
err "unknown profile: $PROFILE. Valid: minimal | core | frontend | ops | dev | full"
exit 1
@ -573,7 +904,11 @@ fi
# Profile-aware soft-warn: only check deps for primitives actually being installed.
# Build a unique set of substrings to check.
PROFILE_PRIMS="$(profile_members "$PROFILE" 2>/dev/null || true)"
if [ "$PROFILE" = "custom" ]; then
PROFILE_PRIMS="$(echo "$CUSTOM_PRIMS" | tr ',' ' ')"
else
PROFILE_PRIMS="$(profile_members "$PROFILE" 2>/dev/null || true)"
fi
needs_pandoc=0
needs_playwright=0
needs_sqlite=0
@ -611,6 +946,22 @@ if [ "$needs_yq" = "1" ] && ! command -v yq >/dev/null 2>&1; then
warn "yq not found — kei-ci-lint requires yq v4+ (mikefarah/yq). Install: brew install yq"
fi
# --- confirm screen + --no-execute short-circuit --------------------------
# Always show the plan; skip confirm when --yes / non-TTY / --add/--remove path.
# The menu branch and the --profile branch both flow through here.
CONFIRM_LABEL="$PROFILE"
[ "$PROFILE" = "custom" ] && CONFIRM_LABEL="custom ($CUSTOM_PRIMS)"
CONFIRM_INPUT="$(printf '%s\n' $PROFILE_PRIMS | grep -v '^$' || true)"
if ! printf '%s\n' "$CONFIRM_INPUT" | show_confirm_screen "$CONFIRM_LABEL"; then
say "install declined at confirm screen — aborting"
exit 1
fi
if [ "$NO_EXECUTE" = "1" ]; then
say "--no-execute: plan resolved, exiting before install"
exit 0
fi
# --- create target dirs ---------------------------------------------------
say "creating directories"
mkdir -p \