diff --git a/README.md b/README.md index 40ba4a2..e3a7430 100644 --- a/README.md +++ b/README.md @@ -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 · diff --git a/bin/kei b/bin/kei index cb69d54..d7532c3 100755 --- a/bin/kei +++ b/bin/kei @@ -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} diff --git a/bootstrap.sh b/bootstrap.sh index 8201a03..a1086ba 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -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="" diff --git a/install.sh b/install.sh index 20df7c8..01fd03e 100755 --- a/install.sh +++ b/install.sh @@ -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 diff --git a/install/lib-hooks.sh b/install/lib-hooks.sh index e9dc4ff..f3fd385 100644 --- a/install/lib-hooks.sh +++ b/install/lib-hooks.sh @@ -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 } diff --git a/install/lib-menu.sh b/install/lib-menu.sh index cf7a5df..4610d95 100644 --- a/install/lib-menu.sh +++ b/install/lib-menu.sh @@ -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 } diff --git a/install/lib-onboarding.sh b/install/lib-onboarding.sh index 91ead5e..6050114 100644 --- a/install/lib-onboarding.sh +++ b/install/lib-onboarding.sh @@ -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 diff --git a/install/lib-plan.sh b/install/lib-plan.sh index 77633b3..39547dc 100644 --- a/install/lib-plan.sh +++ b/install/lib-plan.sh @@ -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 $? diff --git a/install/lib-preflight.sh b/install/lib-preflight.sh index dd3225b..47b3d6e 100644 --- a/install/lib-preflight.sh +++ b/install/lib-preflight.sh @@ -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'тся diff --git a/install/lib-wizard.sh b/install/lib-wizard.sh index f43716b..84367d5 100644 --- a/install/lib-wizard.sh +++ b/install/lib-wizard.sh @@ -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 diff --git a/plugin.json b/plugin.json index fdf9652..8a67924 100644 --- a/plugin.json +++ b/plugin.json @@ -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": { diff --git a/scripts/kei-prompt.sh b/scripts/kei-prompt.sh new file mode 100755 index 0000000..bed5805 --- /dev/null +++ b/scripts/kei-prompt.sh @@ -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/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 [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 [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 +# +# 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 +}