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 <test@example.com>
This commit is contained in:
Frank Bria 2026-01-26 14:47:06 -07:00 committed by GitHub
parent 910f794fcc
commit 05c57207f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 238 additions and 26 deletions

View file

@ -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 <session-name>
### 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

View file

@ -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
}

View file

@ -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 ]]
}