KeiSeiKit-1.0/scripts/kei-prompt.sh
KeiSei84 72bf31ba7a 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>
2026-05-27 16:05:44 +08:00

147 lines
5.2 KiB
Bash
Executable file

#!/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
}