From 05c57207f35cb04149fd0a5b2f479adb72188b49 Mon Sep 17 00:00:00 2001 From: Frank Bria Date: Mon, 26 Jan 2026 14:47:06 -0700 Subject: [PATCH] fix(wizard): redirect prompts to stderr for clean command substitution (#130) When wizard prompt functions (prompt_text, prompt_number, select_option, select_with_default) were used with command substitution, ANSI-colored prompts were captured along with user responses, corrupting .ralphrc values like: PROJECT_NAME="[0;36mProject name[0m [value]: value" Fix: - Redirect all display output (colored prompts, validation messages) to stderr using >&2, matching the pattern already used by select_multiple() - Keep only the actual response/result on stdout for command substitution Changes: - lib/wizard_utils.sh: Add >&2 to prompt_text (lines 84-87), prompt_number (lines 118-121, 131-151), confirm (lines 44, 60), select_option (lines 190-215), select_with_default (lines 330-364) - tests/unit/test_wizard_utils.bats: Add 20 new tests for stdout/stderr separation and clean command substitution results - CLAUDE.md: Update test count to 420 Test count: 420 (up from 396) Co-authored-by: Test User --- CLAUDE.md | 7 +- lib/wizard_utils.sh | 51 ++++---- tests/unit/test_wizard_utils.bats | 206 ++++++++++++++++++++++++++++++ 3 files changed, 238 insertions(+), 26 deletions(-) create mode 100644 tests/unit/test_wizard_utils.bats diff --git a/CLAUDE.md b/CLAUDE.md index 6b8a7d7..6aa29de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co This is the Ralph for Claude Code repository - an autonomous AI development loop system that enables continuous development cycles with intelligent exit detection and rate limiting. -**Version**: v0.11.0 | **Tests**: 396 passing (100% pass rate) | **CI/CD**: GitHub Actions +**Version**: v0.11.0 | **Tests**: 420 passing (100% pass rate) | **CI/CD**: GitHub Actions ## Core Architecture @@ -167,7 +167,7 @@ tmux attach -t ### Running Tests ```bash -# Run all tests (396 tests) +# Run all tests (420 tests) npm test # Run specific test suites @@ -402,7 +402,7 @@ Ralph uses advanced error detection with two-stage filtering to eliminate false ## Test Suite -### Test Files (396 tests total) +### Test Files (420 tests total) | File | Tests | Description | |------|-------|-------------| @@ -420,6 +420,7 @@ Ralph uses advanced error detection with two-stage filtering to eliminate false | `test_enable_core.bats` | 30 | Enable core library (idempotency, project detection, template generation) | | `test_task_sources.bats` | 23 | Task sources (beads, GitHub, PRD extraction, normalization) | | `test_ralph_enable.bats` | 22 | Ralph enable integration tests (wizard, CI version, JSON output) | +| `test_wizard_utils.bats` | 20 | Wizard utility functions (stdout/stderr separation, prompt functions) | ### Running Tests ```bash diff --git a/lib/wizard_utils.sh b/lib/wizard_utils.sh index ca95240..b7f4eac 100755 --- a/lib/wizard_utils.sh +++ b/lib/wizard_utils.sh @@ -41,7 +41,8 @@ confirm() { fi while true; do - echo -en "${WIZARD_CYAN}${prompt}${WIZARD_NC} ${yn_hint}: " + # Display prompt to stderr for consistency with other prompt functions + echo -en "${WIZARD_CYAN}${prompt}${WIZARD_NC} ${yn_hint}: " >&2 read -r response # Handle empty response (use default) @@ -57,7 +58,7 @@ confirm() { return 1 ;; *) - echo -e "${WIZARD_YELLOW}Please answer yes (y) or no (n)${WIZARD_NC}" + echo -e "${WIZARD_YELLOW}Please answer yes (y) or no (n)${WIZARD_NC}" >&2 ;; esac done @@ -80,10 +81,11 @@ prompt_text() { local default="${2:-}" local response + # Display prompt to stderr so command substitution only captures the response if [[ -n "$default" ]]; then - echo -en "${WIZARD_CYAN}${prompt}${WIZARD_NC} [${default}]: " + echo -en "${WIZARD_CYAN}${prompt}${WIZARD_NC} [${default}]: " >&2 else - echo -en "${WIZARD_CYAN}${prompt}${WIZARD_NC}: " + echo -en "${WIZARD_CYAN}${prompt}${WIZARD_NC}: " >&2 fi read -r response @@ -114,10 +116,11 @@ prompt_number() { local response while true; do + # Display prompt to stderr so command substitution only captures the response if [[ -n "$default" ]]; then - echo -en "${WIZARD_CYAN}${prompt}${WIZARD_NC} [${default}]: " + echo -en "${WIZARD_CYAN}${prompt}${WIZARD_NC} [${default}]: " >&2 else - echo -en "${WIZARD_CYAN}${prompt}${WIZARD_NC}: " + echo -en "${WIZARD_CYAN}${prompt}${WIZARD_NC}: " >&2 fi read -r response @@ -128,25 +131,25 @@ prompt_number() { echo "$default" return 0 else - echo -e "${WIZARD_YELLOW}Please enter a number${WIZARD_NC}" + echo -e "${WIZARD_YELLOW}Please enter a number${WIZARD_NC}" >&2 continue fi fi # Validate it's a number if ! [[ "$response" =~ ^[0-9]+$ ]]; then - echo -e "${WIZARD_YELLOW}Please enter a valid number${WIZARD_NC}" + echo -e "${WIZARD_YELLOW}Please enter a valid number${WIZARD_NC}" >&2 continue fi # Check range if specified if [[ -n "$min" && "$response" -lt "$min" ]]; then - echo -e "${WIZARD_YELLOW}Value must be at least ${min}${WIZARD_NC}" + echo -e "${WIZARD_YELLOW}Value must be at least ${min}${WIZARD_NC}" >&2 continue fi if [[ -n "$max" && "$response" -gt "$max" ]]; then - echo -e "${WIZARD_YELLOW}Value must be at most ${max}${WIZARD_NC}" + echo -e "${WIZARD_YELLOW}Value must be at most ${max}${WIZARD_NC}" >&2 continue fi @@ -184,20 +187,21 @@ select_option() { return 1 fi - echo -e "\n${WIZARD_BOLD}${prompt}${WIZARD_NC}" - echo "" + # Display prompt and options to stderr so command substitution only captures the result + echo -e "\n${WIZARD_BOLD}${prompt}${WIZARD_NC}" >&2 + echo "" >&2 # Display options local i=1 for opt in "${options[@]}"; do - echo -e " ${WIZARD_CYAN}${i})${WIZARD_NC} ${opt}" + echo -e " ${WIZARD_CYAN}${i})${WIZARD_NC} ${opt}" >&2 ((i++)) done - echo "" + echo "" >&2 while true; do - echo -en "Select option [1-${num_options}]: " + echo -en "Select option [1-${num_options}]: " >&2 read -r response # Validate it's a number in range @@ -208,7 +212,7 @@ select_option() { echo "${options[$((response - 1))]}" return 0 else - echo -e "${WIZARD_YELLOW}Please enter a number between 1 and ${num_options}${WIZARD_NC}" + echo -e "${WIZARD_YELLOW}Please enter a number between 1 and ${num_options}${WIZARD_NC}" >&2 fi done } @@ -323,24 +327,25 @@ select_with_default() { local options=("$@") local num_options=${#options[@]} - echo -e "\n${WIZARD_BOLD}${prompt}${WIZARD_NC}" - echo "" + # Display prompt and options to stderr so command substitution only captures the result + echo -e "\n${WIZARD_BOLD}${prompt}${WIZARD_NC}" >&2 + echo "" >&2 # Display options with default marked local i=1 for opt in "${options[@]}"; do if [[ $i -eq $default_index ]]; then - echo -e " ${WIZARD_GREEN}${i})${WIZARD_NC} ${opt} ${WIZARD_GREEN}(recommended)${WIZARD_NC}" + echo -e " ${WIZARD_GREEN}${i})${WIZARD_NC} ${opt} ${WIZARD_GREEN}(recommended)${WIZARD_NC}" >&2 else - echo -e " ${WIZARD_CYAN}${i})${WIZARD_NC} ${opt}" + echo -e " ${WIZARD_CYAN}${i})${WIZARD_NC} ${opt}" >&2 fi ((i++)) done - echo "" + echo "" >&2 while true; do - echo -en "Select option [1-${num_options}] (default: ${default_index}): " + echo -en "Select option [1-${num_options}] (default: ${default_index}): " >&2 read -r response # Use default if empty @@ -356,7 +361,7 @@ select_with_default() { echo "${options[$((response - 1))]}" return 0 else - echo -e "${WIZARD_YELLOW}Please enter a number between 1 and ${num_options}${WIZARD_NC}" + echo -e "${WIZARD_YELLOW}Please enter a number between 1 and ${num_options}${WIZARD_NC}" >&2 fi done } diff --git a/tests/unit/test_wizard_utils.bats b/tests/unit/test_wizard_utils.bats new file mode 100644 index 0000000..69e6a5a --- /dev/null +++ b/tests/unit/test_wizard_utils.bats @@ -0,0 +1,206 @@ +#!/usr/bin/env bats +# Unit tests for wizard_utils.sh +# Tests for stdout/stderr separation to prevent ANSI code capture in command substitution + +load '../helpers/test_helper' + +# Path to the library +WIZARD_UTILS="${BATS_TEST_DIRNAME}/../../lib/wizard_utils.sh" + +setup() { + # Source the wizard utils + source "$WIZARD_UTILS" +} + +# ============================================================================= +# PROMPT_TEXT STDOUT/STDERR SEPARATION (4 tests) +# ============================================================================= + +@test "prompt_text returns only user input on stdout, no ANSI codes" { + # Simulate user typing "my-project" followed by Enter + result=$(echo "my-project" | prompt_text "Project name" "default") + + # The captured output should be ONLY "my-project", no ANSI escape codes + [[ "$result" == "my-project" ]] + # Should NOT contain ANSI escape sequence + [[ ! "$result" =~ $'\033' ]] + [[ ! "$result" =~ "Project name" ]] +} + +@test "prompt_text with default returns clean default value on empty input" { + # Simulate user pressing Enter (empty input) + result=$(echo "" | prompt_text "Project name" "default-value") + + # Should return only the default value + [[ "$result" == "default-value" ]] + # Should NOT contain ANSI escape sequence + [[ ! "$result" =~ $'\033' ]] + [[ ! "$result" =~ "Project name" ]] +} + +@test "prompt_text without default returns empty string on empty input" { + # Simulate user pressing Enter (empty input) with no default + result=$(echo "" | prompt_text "Project name") + + # Should return empty string + [[ "$result" == "" ]] +} + +@test "prompt_text handles special characters in user input" { + # Test that special characters are preserved + result=$(echo "my project-name_123" | prompt_text "Name" "default") + + [[ "$result" == "my project-name_123" ]] +} + +# ============================================================================= +# PROMPT_NUMBER STDOUT/STDERR SEPARATION (5 tests) +# ============================================================================= + +@test "prompt_number returns only numeric value on stdout, no ANSI codes" { + # Simulate user typing "100" + result=$(echo "100" | prompt_number "Max calls" "50") + + # The captured output should be ONLY "100", no ANSI codes + [[ "$result" == "100" ]] + # Should NOT contain ANSI escape sequence + [[ ! "$result" =~ $'\033' ]] + [[ ! "$result" =~ "Max calls" ]] +} + +@test "prompt_number with default returns clean default on empty input" { + # Simulate user pressing Enter (empty input) + result=$(echo "" | prompt_number "Max calls" "50") + + # Should return only the default + [[ "$result" == "50" ]] + # Should NOT contain ANSI escape sequence + [[ ! "$result" =~ $'\033' ]] +} + +@test "prompt_number validates min value without ANSI in result" { + # First input is too small, second input is valid + result=$(printf "5\n20" | prompt_number "Value" "" "10" "100") + + # Should return only the valid number + [[ "$result" == "20" ]] + [[ ! "$result" =~ $'\033' ]] +} + +@test "prompt_number validates max value without ANSI in result" { + # First input is too large, second input is valid + result=$(printf "150\n80" | prompt_number "Value" "" "10" "100") + + # Should return only the valid number + [[ "$result" == "80" ]] + [[ ! "$result" =~ $'\033' ]] +} + +@test "prompt_number rejects non-numeric input without ANSI in result" { + # First input is not a number, second input is valid + result=$(printf "abc\n42" | prompt_number "Value" "") + + # Should return only the valid number + [[ "$result" == "42" ]] + [[ ! "$result" =~ $'\033' ]] +} + +# ============================================================================= +# CONFIRM STDOUT/STDERR SEPARATION (3 tests) +# ============================================================================= + +@test "confirm sends prompt to stderr not stdout" { + # Capture stdout only - should be empty + stdout_output=$(echo "y" | confirm "Continue?" 2>/dev/null) + + # Stdout should be empty (confirm uses return codes, not echo) + [[ -z "$stdout_output" ]] +} + +@test "confirm yes response returns 0" { + echo "y" | confirm "Continue?" 2>/dev/null + [[ $? -eq 0 ]] +} + +@test "confirm no response returns 1" { + # Run confirm with "n" response, expect return code 1 (no) + run bash -c 'source '"$WIZARD_UTILS"' && echo "n" | confirm "Continue?" 2>/dev/null' + [[ $status -eq 1 ]] +} + +# ============================================================================= +# SELECT_OPTION STDOUT/STDERR SEPARATION (3 tests) +# ============================================================================= + +@test "select_option returns only selected option on stdout, no ANSI codes" { + # Simulate user selecting option 2 + result=$(echo "2" | select_option "Choose one" "first" "second" "third") + + # Should return only "second" + [[ "$result" == "second" ]] + [[ ! "$result" =~ $'\033' ]] + [[ ! "$result" =~ "Choose one" ]] +} + +@test "select_option handles first option selection" { + result=$(echo "1" | select_option "Choose" "alpha" "beta") + + [[ "$result" == "alpha" ]] + [[ ! "$result" =~ $'\033' ]] +} + +@test "select_option retries on invalid input without ANSI in result" { + # First input is invalid, second input is valid + result=$(printf "abc\n2" | select_option "Choose" "one" "two") + + [[ "$result" == "two" ]] + [[ ! "$result" =~ $'\033' ]] +} + +# ============================================================================= +# SELECT_WITH_DEFAULT STDOUT/STDERR SEPARATION (3 tests) +# ============================================================================= + +@test "select_with_default returns selected option on stdout, no ANSI codes" { + # Simulate user selecting option 2 + result=$(echo "2" | select_with_default "Choose" 1 "default" "other") + + [[ "$result" == "other" ]] + [[ ! "$result" =~ $'\033' ]] +} + +@test "select_with_default returns default on empty input" { + # Simulate user pressing Enter + result=$(echo "" | select_with_default "Choose" 1 "default" "other") + + [[ "$result" == "default" ]] + [[ ! "$result" =~ $'\033' ]] +} + +@test "select_with_default handles non-first default" { + # Simulate empty input with default_index=2 + result=$(echo "" | select_with_default "Choose" 2 "first" "second") + + [[ "$result" == "second" ]] + [[ ! "$result" =~ $'\033' ]] +} + +# ============================================================================= +# INTEGRATION: RALPHRC GENERATION (2 tests) +# ============================================================================= + +@test "prompt_text output is safe for shell variable assignment" { + result=$(echo "test-project" | prompt_text "Project" "default") + + # Result should be safe to assign to a shell variable + eval "PROJECT_NAME=\"$result\"" + [[ "$PROJECT_NAME" == "test-project" ]] +} + +@test "prompt_number output is safe for shell arithmetic" { + result=$(echo "100" | prompt_number "Calls" "50") + + # Result should be a valid number for arithmetic + (( result > 0 )) + [[ "$result" -eq 100 ]] +}