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:
parent
3bc351ec03
commit
72bf31ba7a
12 changed files with 219 additions and 28 deletions
|
|
@ -46,7 +46,7 @@ sleep consolidates 30-session windows into morning markdown reports.
|
||||||
updates, agent regeneration, DNA index refresh, keimd graph
|
updates, agent regeneration, DNA index refresh, keimd graph
|
||||||
reindex. Auto-self-indexing via kei-registry SQLite.
|
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 ·
|
110 Rust crates · 69 skills · 54 hooks · 38 agent manifests ·
|
||||||
86 substrate blocks · 18 capability atoms · 7 substrate roles ·
|
86 substrate blocks · 18 capability atoms · 7 substrate roles ·
|
||||||
|
|
|
||||||
2
bin/kei
2
bin/kei
|
|
@ -248,7 +248,7 @@ ${C1} ██╔═██╗ ██╔══╝ ██║╚════█
|
||||||
${C1} ██║ ██╗███████╗██║███████║███████╗██║${C0}
|
${C1} ██║ ██╗███████╗██║███████║███████╗██║${C0}
|
||||||
${C1} ╚═╝ ╚═╝╚══════╝╚═╝╚══════╝╚══════╝╚═╝${C0}
|
${C1} ╚═╝ ╚═╝╚══════╝╚═╝╚══════╝╚══════╝╚═╝${C0}
|
||||||
|
|
||||||
${C2} KeiSeiKit · substrate v0.48${C0}
|
${C2} KeiSeiKit · substrate v0.49${C0}
|
||||||
${C3} ─────────────────────────────────────${C0}
|
${C3} ─────────────────────────────────────${C0}
|
||||||
primary CLI : ${CV}${PRIMARY}${C0}
|
primary CLI : ${CV}${PRIMARY}${C0}
|
||||||
profile : ${CV}${p}${C0}
|
profile : ${CV}${p}${C0}
|
||||||
|
|
|
||||||
30
bootstrap.sh
30
bootstrap.sh
|
|
@ -55,7 +55,7 @@ prompt_profile() {
|
||||||
# no 105-crate compile, can't half-fail. Matches install.sh's own default
|
# 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
|
# (was "cortex" here → divergent install vs direct install.sh). Opt up with
|
||||||
# --profile=cortex/full-hub.
|
# --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'
|
cat <<'WIZARD'
|
||||||
|
|
||||||
╔═══════════════════════════════════════════════════════════════════╗
|
╔═══════════════════════════════════════════════════════════════════╗
|
||||||
|
|
@ -115,6 +115,30 @@ log() { echo "[bootstrap] $*"; }
|
||||||
err() { echo "[bootstrap] ERROR: $*" >&2; }
|
err() { echo "[bootstrap] ERROR: $*" >&2; }
|
||||||
have() { command -v "$1" >/dev/null 2>&1; }
|
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)"
|
OS="$(uname -s)"
|
||||||
|
|
||||||
# --- 1. OS detection -----------------------------------------------------
|
# --- 1. OS detection -----------------------------------------------------
|
||||||
|
|
@ -271,7 +295,7 @@ log "===========================================================================
|
||||||
# inside this scope correctly reports interactive vs headless. Wizard
|
# inside this scope correctly reports interactive vs headless. Wizard
|
||||||
# itself re-checks and exits cleanly if non-interactive.
|
# itself re-checks and exits cleanly if non-interactive.
|
||||||
ONBOARD_SH="$HOME/.claude/scripts/kei-onboard.sh"
|
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 ""
|
||||||
log "Starting post-install onboarding (pick primary CLI + wire MCP)..."
|
log "Starting post-install onboarding (pick primary CLI + wire MCP)..."
|
||||||
log "Skip with KEI_NO_ONBOARD=1; re-run anytime with 'kei onboard'."
|
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
|
# stdin was reattached to /dev/tty above (when present), so [ -t 0 ] is
|
||||||
# now true under curl|bash too. Simple gate works correctly.
|
# now true under curl|bash too. Simple gate works correctly.
|
||||||
KEI_BIN_PATH="$HOME/.claude/bin/kei"
|
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 ""
|
log ""
|
||||||
printf ' → Запустить kei сейчас? [Y/n] '
|
printf ' → Запустить kei сейчас? [Y/n] '
|
||||||
_reply=""
|
_reply=""
|
||||||
|
|
|
||||||
23
install.sh
23
install.sh
|
|
@ -52,6 +52,27 @@ MANIFEST="$KIT_DIR/_primitives/MANIFEST.toml"
|
||||||
INSTALLED_FILE="$AGENTS_DIR/_primitives/.installed"
|
INSTALLED_FILE="$AGENTS_DIR/_primitives/.installed"
|
||||||
LIB_DIR="$KIT_DIR/install"
|
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) ------
|
# --- source cubes (order matters: logs -> backup -> profile -> rest) ------
|
||||||
# shellcheck source=install/lib-log.sh
|
# shellcheck source=install/lib-log.sh
|
||||||
source "$LIB_DIR/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
|
# 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
|
# wiring (~/.claude/bin), so the `kei` entry-point was not found after a
|
||||||
# curl|bash install. (Same tee/-t1 trap as the onboarding gates.)
|
# 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
|
pathway_install
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -179,17 +179,17 @@ maybe_activate_hooks() {
|
||||||
elif [ ! -f "$settings_file" ]; then
|
elif [ ! -f "$settings_file" ]; then
|
||||||
say "no existing settings.json; installing snippet"
|
say "no existing settings.json; installing snippet"
|
||||||
activate_hooks && DID_ACTIVATE=1
|
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
|
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
|
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
|
fi
|
||||||
local reply
|
|
||||||
read -r reply
|
|
||||||
case "$reply" in
|
|
||||||
y|Y|yes|YES) activate_hooks && DID_ACTIVATE=1 ;;
|
|
||||||
*) say "skipping hook activation" ;;
|
|
||||||
esac
|
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ menu_should_skip() {
|
||||||
[ -n "$ADD_LIST" ] && return 0
|
[ -n "$ADD_LIST" ] && return 0
|
||||||
[ -n "$REMOVE_NAME" ] && return 0
|
[ -n "$REMOVE_NAME" ] && return 0
|
||||||
[ "$LIST_MODE" = "1" ] && 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
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,11 +43,10 @@ REGISTRY_MODELS="$KIT_DIR/_blocks/registries/models.toml"
|
||||||
onboarding_should_run() {
|
onboarding_should_run() {
|
||||||
[ -f "$ONBOARDED_FLAG" ] && return 1
|
[ -f "$ONBOARDED_FLAG" ] && return 1
|
||||||
[ "${KEISEI_SKIP_ONBOARD:-}" = "1" ] && return 1
|
[ "${KEISEI_SKIP_ONBOARD:-}" = "1" ] && return 1
|
||||||
# Interactive iff stdin is a terminal. We deliberately do NOT require -t 1:
|
# v0.49: delegate to the kei-prompt cube — covers both plain bash AND
|
||||||
# the curl|bash bootstrapper (web-install.sh) tees stdout to a logfile, so
|
# curl|bash (where stdin is the pipe from curl, so [ -t 0 ] is false
|
||||||
# -t 1 is false even in an interactive session. Prompts go to stderr, input
|
# even with the user at a real terminal — only /dev/tty is reliable).
|
||||||
# reads from stdin — an interactive stdin is the only real requirement.
|
kei_is_interactive || return 1
|
||||||
[ ! -t 0 ] && return 1
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,8 +71,8 @@ onboarding_run() {
|
||||||
if ! preflight_run "$ONBOARDING_PROVIDER"; then
|
if ! preflight_run "$ONBOARDING_PROVIDER"; then
|
||||||
echo "" >&2
|
echo "" >&2
|
||||||
echo " ⚠ ${STR_PREFLIGHT_FAILED:-Preflight failed — provider may not work.}" >&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
|
if kei_is_interactive; then # /dev/tty-aware: covers curl|bash
|
||||||
read -r -p " ${STR_PREFLIGHT_CONTINUE:-Continue anyway? [y/N]} " _ans
|
_ans=$(kei_prompt " ${STR_PREFLIGHT_CONTINUE:-Continue anyway? [y/N]} " "N")
|
||||||
case "$_ans" in
|
case "$_ans" in
|
||||||
y|Y|yes|да|Да)
|
y|Y|yes|да|Да)
|
||||||
echo " → продолжаю; ключи запишутся но runtime может упасть." >&2
|
echo " → продолжаю; ключи запишутся но runtime может упасть." >&2
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,7 @@ show_confirm_screen() {
|
||||||
local profile_label="$1"
|
local profile_label="$1"
|
||||||
print_plan_body "$profile_label"
|
print_plan_body "$profile_label"
|
||||||
[ "$ASSUME_YES" = "1" ] && { echo "(--yes: auto-confirming)"; return 0; }
|
[ "$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
|
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
|
whiptail --yesno "Install ${CONFIRM_TOTAL:-0} primitive(s) for profile '$profile_label'?\n\nTime: ~${CONFIRM_SECS}s, disk: ~${CONFIRM_MB} MB" 14 70
|
||||||
return $?
|
return $?
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,9 @@ preflight_offer_install() {
|
||||||
echo " ⚠ $cli не найден." >&2
|
echo " ⚠ $cli не найден." >&2
|
||||||
echo " Установить: $install_cmd" >&2
|
echo " Установить: $install_cmd" >&2
|
||||||
echo "" >&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
|
echo " ⓘ команда: $install_cmd" >&2
|
||||||
read -r -p " Поставить сейчас? [y/N/skip] " ans
|
ans=$(kei_prompt " Поставить сейчас? [y/N/skip] " "N")
|
||||||
case "$ans" in
|
case "$ans" in
|
||||||
y|Y|yes)
|
y|Y|yes)
|
||||||
# bash -c вместо eval — explicit subshell, не word-splitting'тся
|
# bash -c вместо eval — explicit subshell, не word-splitting'тся
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
|
|
||||||
run_sleep_wizard() {
|
run_sleep_wizard() {
|
||||||
local sleep_helper="$AGENTS_DIR/_primitives/kei-sleep-setup.sh"
|
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"
|
say "running sleep-sync setup helper"
|
||||||
"$sleep_helper" || warn "sleep-sync setup did not complete — re-run via /sleep-setup"
|
"$sleep_helper" || warn "sleep-sync setup did not complete — re-run via /sleep-setup"
|
||||||
else
|
else
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
"name": "keisei",
|
"name": "keisei",
|
||||||
"displayName": "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.",
|
"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",
|
"homepage": "https://keisei.app",
|
||||||
"repository": "https://github.com/KeiSeiLab/KeiSeiKit-1.0.git",
|
"repository": "https://github.com/KeiSeiLab/KeiSeiKit-1.0.git",
|
||||||
"author": {
|
"author": {
|
||||||
|
|
|
||||||
147
scripts/kei-prompt.sh
Executable file
147
scripts/kei-prompt.sh
Executable 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
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue