Merge branch 'feat/v0.9.1-interactive-menu' — interactive installer menu
This commit is contained in:
commit
75bceaf3a6
2 changed files with 364 additions and 4 deletions
|
|
@ -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>`.
|
||||
|
|
|
|||
359
install.sh
359
install.sh
|
|
@ -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 \
|
||||
|
|
|
|||
Loading…
Reference in a new issue