diff --git a/README.md b/README.md index f0ad1e3..c9211a8 100644 --- a/README.md +++ b/README.md @@ -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=` or one-at-a-time via `--add=`. diff --git a/install.sh b/install.sh index 3264eff..da541ff 100755 --- a/install.sh +++ b/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= # minimal|core|frontend|ops|dev|full +# ./install.sh # interactive menu on TTY; profile=minimal on non-TTY +# ./install.sh --profile= # minimal|core|frontend|ops|dev|full (skips menu) # ./install.sh --add=[,] # install one or more primitives on top of current state # ./install.sh --remove= # 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 < 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 \