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>
147 lines
5.2 KiB
Bash
Executable file
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
|
|
}
|