refactor(installer): v0.49 — extract kei-prompt cube as interactivity SSoT

Constructor Pattern fix replacing v0.47-v0.48 patch series. The "is the
user interactive?" logic was previously duplicated across 15+ places:

  bootstrap.sh        x4  ([ -t 0 ] gates on profile/onboard/launch/etc)
  install.sh          x1  (PATH wiring decision)
  install/lib-hooks.sh        (activate-hooks prompt)
  install/lib-plan.sh         (auto-confirm gate)
  install/lib-menu.sh         (skip-menu gate)
  install/lib-wizard.sh       (sleep-wizard gate)
  install/lib-onboarding.sh  x2  (onboarding_should_run + preflight retry)
  install/lib-preflight.sh    (install-tool prompt)

Every duplicated check was a chance to get curl|bash semantics wrong.
v0.47 used `[ -t 1 ]` (broke under tee'd stdout). v0.48 used `[ -t 0 ]`
(broke under curl pipe stdin). Each fix was a patch on top of the same
architectural defect: scattered truth.

ARCHITECTURAL FIX (Rule Zero — 1 cube = 1 responsibility):

  scripts/kei-prompt.sh  (NEW, ~110 LOC, public API):
    kei_is_interactive          → 0 if user is at a terminal, 1 if headless
    kei_prompt    Q [DEFAULT]   → answer or default to stdout
    kei_prompt_yn Q [Y|N]       → exit 0=yes, 1=no, with [Y/n] hint
    kei_prompt_secret Q         → no-echo input (tokens, keys)

  Truth signal: /dev/tty accessibility, with [ -t 0 ] as second-choice
  fallback. KEI_NONINTERACTIVE=1 for CI override. Same contract as the
  inline rules — now in ONE place.

  bootstrap.sh + install.sh: source the cube at the top, with self-
  contained inline fallback (mirrors the kei_is_interactive contract
  only) so they remain self-bootable even if scripts/ is missing.

  All 15+ inline gates replaced with `kei_is_interactive` calls.
  All 3 `read -r -p` prompts in installer cubes replaced with
  `kei_prompt` / `kei_prompt_yn`.

  Existing copy_pet_scripts() in lib-scaffold.sh installs scripts/*.sh
  into ~/.claude/scripts/ automatically — no install logic change needed.

WHAT THIS PREVENTS:

- Next time someone writes a prompt in installer code, the only path
  is `kei_prompt`. They CANNOT accidentally type `[ -t 0 ]` because
  there is no `[ -t 0 ]` to copy-paste anymore (except inside the
  cube itself).

- The v2 tty-interactivity-gate-guard.sh hook (added 2026-05-27)
  becomes a regression net rather than the first line of defence.

- Two real install incidents this month (May 2026, 7 prompts each)
  do not happen a third time.

VERIFIED:
- Syntax check passes on all 9 modified files + new cube.
- Primitive functions smoke-tested across 8 cases: headless,
  KEI_NONINTERACTIVE override, default fallback, yn convenience,
  re-source guard, /dev/tty available, /dev/tty open() fails.
- 2 remaining [ -t 0 ] in tree: BOTH inside kei_is_interactive
  fallback in bootstrap.sh + install.sh (single-source contract,
  not patches).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
KeiSei84 2026-05-27 16:05:44 +08:00
parent 3bc351ec03
commit 72bf31ba7a
12 changed files with 219 additions and 28 deletions

View file

@ -46,7 +46,7 @@ sleep consolidates 30-session windows into morning markdown reports.
updates, agent regeneration, DNA index refresh, keimd graph
reindex. Auto-self-indexing via kei-registry SQLite.
## By the numbers (v0.47)
## By the numbers (v0.49)
110 Rust crates · 69 skills · 54 hooks · 38 agent manifests ·
86 substrate blocks · 18 capability atoms · 7 substrate roles ·

View file

@ -248,7 +248,7 @@ ${C1} ██╔═██╗ ██╔══╝ ██║╚════█
${C1} ██║ ██╗███████╗██║███████║███████╗██║${C0}
${C1} ╚═╝ ╚═╝╚══════╝╚═╝╚══════╝╚══════╝╚═╝${C0}
${C2} KeiSeiKit · substrate v0.48${C0}
${C2} KeiSeiKit · substrate v0.49${C0}
${C3} ─────────────────────────────────────${C0}
primary CLI : ${CV}${PRIMARY}${C0}
profile : ${CV}${p}${C0}

View file

@ -55,7 +55,7 @@ prompt_profile() {
# no 105-crate compile, can't half-fail. Matches install.sh's own default
# (was "cortex" here → divergent install vs direct install.sh). Opt up with
# --profile=cortex/full-hub.
if [ ! -t 0 ]; then PROFILE="minimal"; return 0; fi
if ! kei_is_interactive; then PROFILE="minimal"; return 0; fi
cat <<'WIZARD'
╔═══════════════════════════════════════════════════════════════════╗
@ -115,6 +115,30 @@ log() { echo "[bootstrap] $*"; }
err() { echo "[bootstrap] ERROR: $*" >&2; }
have() { command -v "$1" >/dev/null 2>&1; }
# v0.49: source the interactive-prompt cube (Constructor Pattern: ONE place
# where all interactivity logic lives). Tries kit-local path first (when
# running from a clone / curl|bash via cloned checkout), then installed
# path (when bootstrap re-runs from $HOME/.claude). Last-resort inline
# fallback if neither found — keeps the script self-bootable.
_KIT_DIR_PRE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -r "$_KIT_DIR_PRE/scripts/kei-prompt.sh" ]; then
# shellcheck source=scripts/kei-prompt.sh
. "$_KIT_DIR_PRE/scripts/kei-prompt.sh"
elif [ -r "$HOME/.claude/scripts/kei-prompt.sh" ]; then
# shellcheck disable=SC1091
. "$HOME/.claude/scripts/kei-prompt.sh"
else
# Self-contained fallback so bootstrap never breaks when run from a
# weird directory. Mirrors kei_is_interactive's contract only.
kei_is_interactive() {
[ "${KEI_NONINTERACTIVE:-0}" = "1" ] && return 1
if [ -r /dev/tty ] && [ -w /dev/tty ]; then return 0; fi
[ -t 0 ] && return 0
return 1
}
fi
unset _KIT_DIR_PRE
OS="$(uname -s)"
# --- 1. OS detection -----------------------------------------------------
@ -271,7 +295,7 @@ log "===========================================================================
# inside this scope correctly reports interactive vs headless. Wizard
# itself re-checks and exits cleanly if non-interactive.
ONBOARD_SH="$HOME/.claude/scripts/kei-onboard.sh"
if [ -x "$ONBOARD_SH" ] && [ -t 0 ] && [ "${KEI_NO_ONBOARD:-0}" != "1" ]; then
if [ -x "$ONBOARD_SH" ] && kei_is_interactive && [ "${KEI_NO_ONBOARD:-0}" != "1" ]; then
log ""
log "Starting post-install onboarding (pick primary CLI + wire MCP)..."
log "Skip with KEI_NO_ONBOARD=1; re-run anytime with 'kei onboard'."
@ -297,7 +321,7 @@ log " - For sleep layer: run /sleep-setup inside Claude Code."
# stdin was reattached to /dev/tty above (when present), so [ -t 0 ] is
# now true under curl|bash too. Simple gate works correctly.
KEI_BIN_PATH="$HOME/.claude/bin/kei"
if [ -x "$KEI_BIN_PATH" ] && [ -t 0 ] && [ "${KEI_NO_AUTORUN:-0}" != "1" ]; then
if [ -x "$KEI_BIN_PATH" ] && kei_is_interactive && [ "${KEI_NO_AUTORUN:-0}" != "1" ]; then
log ""
printf ' → Запустить kei сейчас? [Y/n] '
_reply=""

View file

@ -52,6 +52,27 @@ MANIFEST="$KIT_DIR/_primitives/MANIFEST.toml"
INSTALLED_FILE="$AGENTS_DIR/_primitives/.installed"
LIB_DIR="$KIT_DIR/install"
# --- v0.49: interactive-prompt cube (Constructor Pattern SSoT) -----------
# ALL interactive logic — `kei_is_interactive`, `kei_prompt`, `kei_prompt_yn`,
# `kei_prompt_secret` — lives in scripts/kei-prompt.sh. NEVER inline
# `[ -t 0 ]` or `read -r` in installer code. Source it BEFORE other libs
# so they can use the helpers.
if [ -r "$KIT_DIR/scripts/kei-prompt.sh" ]; then
# shellcheck source=scripts/kei-prompt.sh
source "$KIT_DIR/scripts/kei-prompt.sh"
elif [ -r "$HOME/.claude/scripts/kei-prompt.sh" ]; then
# shellcheck disable=SC1091
source "$HOME/.claude/scripts/kei-prompt.sh"
else
# Self-contained fallback — same contract as the cube's kei_is_interactive.
kei_is_interactive() {
[ "${KEI_NONINTERACTIVE:-0}" = "1" ] && return 1
if [ -r /dev/tty ] && [ -w /dev/tty ]; then return 0; fi
[ -t 0 ] && return 0
return 1
}
fi
# --- source cubes (order matters: logs -> backup -> profile -> rest) ------
# shellcheck source=install/lib-log.sh
source "$LIB_DIR/lib-log.sh"
@ -267,7 +288,7 @@ if [ "$NO_PATHWAY" != "1" ]; then
# logfile, so -t 1 is false even interactively. Requiring it skipped PATH
# wiring (~/.claude/bin), so the `kei` entry-point was not found after a
# curl|bash install. (Same tee/-t1 trap as the onboarding gates.)
if [ "$WITH_PATHWAY" = "1" ] || [ -t 0 ]; then
if [ "$WITH_PATHWAY" = "1" ] || kei_is_interactive; then
pathway_install
fi
fi

View file

@ -179,17 +179,17 @@ maybe_activate_hooks() {
elif [ ! -f "$settings_file" ]; then
say "no existing settings.json; installing snippet"
activate_hooks && DID_ACTIVATE=1
elif [ -t 0 ]; then # stdin-only: stdout may be tee'd in curl|bash
elif kei_is_interactive; then # /dev/tty-aware: covers curl|bash
local _hooks_q
if [ "$COLOR" = "1" ]; then
printf '\033[1;36m[install]\033[0m activate hooks now? [y/N] '
_hooks_q=$'\033[1;36m[install]\033[0m activate hooks now?'
else
printf '[install] activate hooks now? [y/N] '
_hooks_q='[install] activate hooks now?'
fi
if kei_prompt_yn "$_hooks_q" "N"; then
activate_hooks && DID_ACTIVATE=1
else
say "skipping hook activation"
fi
local reply
read -r reply
case "$reply" in
y|Y|yes|YES) activate_hooks && DID_ACTIVATE=1 ;;
*) say "skipping hook activation" ;;
esac
fi
}

View file

@ -17,7 +17,7 @@ menu_should_skip() {
[ -n "$ADD_LIST" ] && return 0
[ -n "$REMOVE_NAME" ] && return 0
[ "$LIST_MODE" = "1" ] && return 0
[ ! -t 0 ] && return 0 # interactive stdin only; not -t 1 (curl|bash tees stdout)
kei_is_interactive || return 0 # /dev/tty-aware: covers curl|bash + plain bash
return 1
}

View file

@ -43,11 +43,10 @@ REGISTRY_MODELS="$KIT_DIR/_blocks/registries/models.toml"
onboarding_should_run() {
[ -f "$ONBOARDED_FLAG" ] && return 1
[ "${KEISEI_SKIP_ONBOARD:-}" = "1" ] && return 1
# Interactive iff stdin is a terminal. We deliberately do NOT require -t 1:
# the curl|bash bootstrapper (web-install.sh) tees stdout to a logfile, so
# -t 1 is false even in an interactive session. Prompts go to stderr, input
# reads from stdin — an interactive stdin is the only real requirement.
[ ! -t 0 ] && return 1
# v0.49: delegate to the kei-prompt cube — covers both plain bash AND
# curl|bash (where stdin is the pipe from curl, so [ -t 0 ] is false
# even with the user at a real terminal — only /dev/tty is reliable).
kei_is_interactive || return 1
return 0
}
@ -72,8 +71,8 @@ onboarding_run() {
if ! preflight_run "$ONBOARDING_PROVIDER"; then
echo "" >&2
echo "${STR_PREFLIGHT_FAILED:-Preflight failed — provider may not work.}" >&2
if [ -t 0 ]; then # stdin-only: stdout may be tee'd in curl|bash
read -r -p " ${STR_PREFLIGHT_CONTINUE:-Continue anyway? [y/N]} " _ans
if kei_is_interactive; then # /dev/tty-aware: covers curl|bash
_ans=$(kei_prompt " ${STR_PREFLIGHT_CONTINUE:-Continue anyway? [y/N]} " "N")
case "$_ans" in
y|Y|yes|да|Да)
echo " → продолжаю; ключи запишутся но runtime может упасть." >&2

View file

@ -137,7 +137,7 @@ 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; }
kei_is_interactive || { 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 $?

View file

@ -25,9 +25,9 @@ preflight_offer_install() {
echo "$cli не найден." >&2
echo " Установить: $install_cmd" >&2
echo "" >&2
if [ -t 0 ]; then # stdin-only: stdout may be tee'd in curl|bash
if kei_is_interactive; then # /dev/tty-aware: covers curl|bash
echo " ⓘ команда: $install_cmd" >&2
read -r -p " Поставить сейчас? [y/N/skip] " ans
ans=$(kei_prompt " Поставить сейчас? [y/N/skip] " "N")
case "$ans" in
y|Y|yes)
# bash -c вместо eval — explicit subshell, не word-splitting'тся

View file

@ -10,7 +10,7 @@
run_sleep_wizard() {
local sleep_helper="$AGENTS_DIR/_primitives/kei-sleep-setup.sh"
if [[ -x "$sleep_helper" ]] && [ -t 0 ]; then # stdin only; not -t 1 (curl|bash tees stdout)
if [[ -x "$sleep_helper" ]] && kei_is_interactive; then # /dev/tty-aware: covers curl|bash
say "running sleep-sync setup helper"
"$sleep_helper" || warn "sleep-sync setup did not complete — re-run via /sleep-setup"
else

View file

@ -3,7 +3,7 @@
"name": "keisei",
"displayName": "KeiSei",
"description": "Constructor Pattern multi-LLM agent substrate — 38 agents, 69 skills, 54 hooks, 86 blocks. Cross-CLI policy enforcement (Claude/Grok/Copilot/Agy/Kimi) via kei-mcp + kei_bash/kei_edit/kei_write. Rust primitives via classic ./install.sh.",
"version": "0.48.0",
"version": "0.49.0",
"homepage": "https://keisei.app",
"repository": "https://github.com/KeiSeiLab/KeiSeiKit-1.0.git",
"author": {

147
scripts/kei-prompt.sh Executable file
View file

@ -0,0 +1,147 @@
#!/usr/bin/env bash
# kei-prompt — единственный cube для интерактивного ввода (Constructor Pattern).
#
# Source it, then use the functions. NEVER inline `[ -t 0 ]` + `read` in
# installer / bootstrap shell files — call these helpers instead.
#
# Why this exists (2026-05-27 architectural fix):
# - `[ -t 1 ]` fails under curl|bash (stdout tee'd) → rule v1.
# - `[ -t 0 ]` ALSO fails under curl|bash (stdin = pipe from curl) → rule v2.
# - The ONLY reliable interactive signal is /dev/tty accessibility.
# - Spreading that check across 15+ files invites the same bug forever.
# - One cube, one truth: kei_is_interactive(). All callers are downstream.
#
# Public API (alphabetical):
# kei_is_interactive → 0 if user is at a terminal, 1 if headless
# kei_prompt Q [DEFAULT] → echo answer (or DEFAULT) to stdout
# kei_prompt_yn Q [Y|N] → exit 0 if user said yes, 1 otherwise
# kei_prompt_secret Q → echo answer (no echo on terminal) to stdout
#
# Overrides:
# KEI_NONINTERACTIVE=1 → all helpers behave as if headless (CI override)
# Re-source guard — sourcing twice should be a no-op.
[ "${_KEI_PROMPT_SOURCED:-0}" = "1" ] && return 0
_KEI_PROMPT_SOURCED=1
# ---------------------------------------------------------------------------
# kei_is_interactive
#
# Returns 0 (interactive) when ANY of:
# - /dev/tty is readable AND writable (covers curl|bash, where stdin is
# a pipe from curl but the terminal is still attached at fd /dev/tty)
# - stdin is a tty (covers plain `./bootstrap.sh` invocation)
# Returns 1 (headless) when:
# - KEI_NONINTERACTIVE=1 (explicit CI override)
# - none of the above signals are present
#
# Use this EVERYWHERE instead of `[ -t 0 ]` or `[ -t 1 ]`.
kei_is_interactive() {
[ "${KEI_NONINTERACTIVE:-0}" = "1" ] && return 1
if [ -r /dev/tty ] && [ -w /dev/tty ]; then
return 0
fi
if [ -t 0 ]; then
return 0
fi
return 1
}
# ---------------------------------------------------------------------------
# _kei_read_from_tty — internal: read one line from /dev/tty if openable,
# else from stdin. Echoes the line via the variable name passed in $1.
#
# Note: we try to OPEN /dev/tty (not just `[ -r /dev/tty ]`) — in some
# sandboxes the file exists but open() returns ENXIO ("Device not
# configured"). Both stages must be silent on failure so the prompt
# UI stays clean.
_kei_read_from_tty() {
local _varname="$1"
local _line=""
if { exec 3</dev/tty; } 2>/dev/null; then
IFS= read -r _line <&3 || _line=""
exec 3<&-
else
IFS= read -r _line || _line=""
fi
# POSIX-safe assignment to caller's variable.
eval "$_varname=\$_line"
}
# ---------------------------------------------------------------------------
# kei_prompt <question> [default]
#
# Prints `question` to stderr (so it shows even when stdout is captured).
# Reads user input from /dev/tty (with stdin fallback).
# Echoes the answer to stdout — or `default` if user pressed Enter / headless.
# Always returns 0 (never fails the caller).
kei_prompt() {
local q="${1:-}"
local def="${2:-}"
local ans=""
if ! kei_is_interactive; then
printf '%s' "$def"
return 0
fi
printf '%s' "$q" >&2
_kei_read_from_tty ans
printf '%s' "${ans:-$def}"
return 0
}
# ---------------------------------------------------------------------------
# kei_prompt_yn <question> [default=Y|N]
#
# Yes/no convenience. Returns:
# 0 — user said yes (or default was Y and they pressed Enter / headless)
# 1 — user said no (or default was N and they pressed Enter / headless)
# The hint `[Y/n]` / `[y/N]` is appended automatically based on `default`.
kei_prompt_yn() {
local q="${1:-}"
local def="${2:-Y}"
local hint=""
case "$def" in
[Yy]*) hint="[Y/n]"; def="Y" ;;
[Nn]*) hint="[y/N]"; def="N" ;;
*) hint="[y/n]"; def="N" ;;
esac
local ans
ans="$(kei_prompt "$q $hint " "$def")"
case "${ans:-$def}" in
[Yy]*) return 0 ;;
*) return 1 ;;
esac
}
# ---------------------------------------------------------------------------
# kei_prompt_secret <question>
#
# Like kei_prompt but with echo disabled on the terminal (for tokens, keys).
# Returns 1 if no terminal — secret input should not be silently defaulted.
# Echoes the secret to stdout; caller is responsible for not logging it.
kei_prompt_secret() {
local q="${1:-}"
local ans=""
if ! kei_is_interactive; then
return 1
fi
printf '%s' "$q" >&2
# Prefer /dev/tty so the secret never touches stdin pipe.
local _src=/dev/stdin
[ -r /dev/tty ] && _src=/dev/tty
# `read -s` is bash-only; use stty -echo for POSIX portability.
if command -v stty >/dev/null 2>&1; then
local _state
_state="$(stty -g <"$_src" 2>/dev/null || echo)"
stty -echo <"$_src" 2>/dev/null || true
IFS= read -r ans <"$_src" || ans=""
[ -n "$_state" ] && stty "$_state" <"$_src" 2>/dev/null || stty echo <"$_src" 2>/dev/null
printf '\n' >&2
else
IFS= read -r ans <"$_src" || ans=""
fi
printf '%s' "$ans"
return 0
}