ralph-claude-code/lib/wizard_utils.sh
Frank Bria 910f794fcc
feat(enable): add ralph-enable wizard for existing projects (v0.11.0) (#124)
* feat(enable): add ralph-enable wizard for existing projects (v0.11.0)

Add interactive wizard and CI version for enabling Ralph in existing projects.

New commands:
- ralph-enable: Interactive 5-phase wizard for humans
- ralph-enable-ci: Non-interactive version with JSON output for CI/automation

New library components:
- lib/enable_core.sh: Shared logic for idempotency, project detection, templates
- lib/wizard_utils.sh: Interactive prompt utilities
- lib/task_sources.sh: Task import from beads, GitHub Issues, PRD documents

Features:
- Auto-detects project type (TypeScript, Python, Rust, Go)
- Auto-detects framework (Next.js, FastAPI, Django, Express)
- Imports tasks from beads, GitHub Issues, or PRD documents
- Generates .ralphrc project configuration file
- Idempotent: safe to run multiple times, respects existing files
- Exit codes: 0 (success), 1 (error), 2 (already enabled)

Updated:
- install.sh: Added new commands to global installation
- ralph_loop.sh: Loads .ralphrc configuration at startup

Tests: 75 new tests (30 enable_core + 23 task_sources + 22 integration)
Total: 396 tests passing (100% pass rate)

Closes #85, #121, #64, #87, #99

* fix(enable): address code review feedback

Fixes from PR #124 review:

1. sed -i portability (ralph_enable.sh:456)
   - Use portable sed + mv pattern instead of GNU-only sed -i

2. sed regex portability (lib/task_sources.sh)
   - Replace \s with POSIX [[:space:]] character class
   - Add sed -E flag for extended regex

3. jq availability check (ralph_enable_ci.sh:177)
   - Add check for jq when --json flag is used

4. Unused filter parameter (lib/task_sources.sh:44)
   - Pass filter to bd list --filter command

5. Word-splitting in select_multiple (ralph_enable.sh:322)
   - Return comma-separated indices instead of space-separated text
   - Update caller to parse indices correctly

6. Missing || true for check_existing_ralph (ralph_enable.sh:185)
   - Prevent set -e from exiting on non-zero return

7. select_multiple stdout corruption (lib/wizard_utils.sh)
   - Redirect interactive output to stderr
   - Only final result goes to stdout

8. Color variables not exported (lib/wizard_utils.sh:12)
   - Export WIZARD_* color variables for subshells

9. select_option infinite loop (lib/wizard_utils.sh:179)
   - Add guard for empty options array

* fix(tests): add missing mocks and exports for new enable feature

- Add RESPONSE_ANALYSIS_FILE export to test_session_continuity.bats setup
- Add mock ralph_enable.sh and ralph_enable_ci.sh to test_installation.bats
- Add mock lib files: enable_core.sh, wizard_utils.sh, task_sources.sh, timeout_utils.sh

All 396 tests now pass.

* fix(config): fix critical issues from PR review

1. .ralphrc Configuration Loading Fix:
   - Captured env var state BEFORE setting defaults with _env_* variables
   - load_ralphrc now only restores values explicitly set by environment
   - .ralphrc settings are now properly applied (not overwritten by defaults)

2. sed Command Injection Fix:
   - Replaced sed with awk for .ralphrc updates in ralph_enable.sh
   - awk -v pattern safely handles user input without shell injection risk

3. Shell Injection Fix in safe_create_file():
   - Replaced echo with printf '%s\n' for safer content handling
   - Prevents issues with backslashes, -n, and special characters

4. Specific Error Codes:
   - Added ENABLE_INVALID_ARGS=3 for argument errors
   - Added ENABLE_FILE_NOT_FOUND=4 for missing files
   - Added ENABLE_DEPENDENCY_MISSING=5 for missing deps (e.g., jq)
   - Added ENABLE_PERMISSION_DENIED=6 for permission errors
   - Updated ralph_enable.sh and ralph_enable_ci.sh to use specific codes

5. Added tests for .ralphrc loading pattern verification

Test count: 398 (up from 396)

* fix(enable): make --force flag actually overwrite existing files

The --force flag was accepted but safe_create_file() always skipped
existing files regardless of ENABLE_FORCE value.

Changes:
- safe_create_file() now checks ENABLE_FORCE environment variable
- When ENABLE_FORCE="true", overwrites existing files instead of skipping
- Added proper logging for overwrite operations

Added tests:
- Verify enable_ralph_in_directory actually changes file contents with --force
- Test safe_create_file overwrites when ENABLE_FORCE is true
- Test safe_create_file skips when ENABLE_FORCE is false

Test count: 400 (up from 398)

---------

Co-authored-by: Test User <test@example.com>
2026-01-25 14:37:25 -07:00

542 lines
14 KiB
Bash
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# wizard_utils.sh - Interactive prompt utilities for Ralph enable wizard
# Provides consistent, user-friendly prompts for configuration
# Colors (exported for subshells)
export WIZARD_CYAN='\033[0;36m'
export WIZARD_GREEN='\033[0;32m'
export WIZARD_YELLOW='\033[1;33m'
export WIZARD_RED='\033[0;31m'
export WIZARD_BOLD='\033[1m'
export WIZARD_NC='\033[0m'
# =============================================================================
# BASIC PROMPTS
# =============================================================================
# confirm - Ask a yes/no question
#
# Parameters:
# $1 (prompt) - The question to ask
# $2 (default) - Default answer: "y" or "n" (optional, defaults to "n")
#
# Returns:
# 0 - User answered yes
# 1 - User answered no
#
# Example:
# if confirm "Continue with installation?" "y"; then
# echo "Installing..."
# fi
#
confirm() {
local prompt=$1
local default="${2:-n}"
local response
local yn_hint="[y/N]"
if [[ "${default,,}" == "y" ]]; then
yn_hint="[Y/n]"
fi
while true; do
echo -en "${WIZARD_CYAN}${prompt}${WIZARD_NC} ${yn_hint}: "
read -r response
# Handle empty response (use default)
if [[ -z "$response" ]]; then
response="$default"
fi
case "${response,,}" in
y|yes)
return 0
;;
n|no)
return 1
;;
*)
echo -e "${WIZARD_YELLOW}Please answer yes (y) or no (n)${WIZARD_NC}"
;;
esac
done
}
# prompt_text - Ask for text input with optional default
#
# Parameters:
# $1 (prompt) - The prompt text
# $2 (default) - Default value (optional)
#
# Outputs:
# Echoes the user's input (or default if empty)
#
# Example:
# project_name=$(prompt_text "Project name" "my-project")
#
prompt_text() {
local prompt=$1
local default="${2:-}"
local response
if [[ -n "$default" ]]; then
echo -en "${WIZARD_CYAN}${prompt}${WIZARD_NC} [${default}]: "
else
echo -en "${WIZARD_CYAN}${prompt}${WIZARD_NC}: "
fi
read -r response
if [[ -z "$response" ]]; then
echo "$default"
else
echo "$response"
fi
}
# prompt_number - Ask for numeric input with optional default and range
#
# Parameters:
# $1 (prompt) - The prompt text
# $2 (default) - Default value (optional)
# $3 (min) - Minimum value (optional)
# $4 (max) - Maximum value (optional)
#
# Outputs:
# Echoes the validated number
#
prompt_number() {
local prompt=$1
local default="${2:-}"
local min="${3:-}"
local max="${4:-}"
local response
while true; do
if [[ -n "$default" ]]; then
echo -en "${WIZARD_CYAN}${prompt}${WIZARD_NC} [${default}]: "
else
echo -en "${WIZARD_CYAN}${prompt}${WIZARD_NC}: "
fi
read -r response
# Use default if empty
if [[ -z "$response" ]]; then
if [[ -n "$default" ]]; then
echo "$default"
return 0
else
echo -e "${WIZARD_YELLOW}Please enter a number${WIZARD_NC}"
continue
fi
fi
# Validate it's a number
if ! [[ "$response" =~ ^[0-9]+$ ]]; then
echo -e "${WIZARD_YELLOW}Please enter a valid number${WIZARD_NC}"
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}"
continue
fi
if [[ -n "$max" && "$response" -gt "$max" ]]; then
echo -e "${WIZARD_YELLOW}Value must be at most ${max}${WIZARD_NC}"
continue
fi
echo "$response"
return 0
done
}
# =============================================================================
# SELECTION PROMPTS
# =============================================================================
# select_option - Present a list of options for single selection
#
# Parameters:
# $1 (prompt) - The question/prompt text
# $@ (options) - Remaining arguments are the options
#
# Outputs:
# Echoes the selected option (the text, not the number)
#
# Example:
# choice=$(select_option "Select package manager" "npm" "yarn" "pnpm")
# echo "Selected: $choice"
#
select_option() {
local prompt=$1
shift
local options=("$@")
local num_options=${#options[@]}
# Guard against empty options array
if [[ $num_options -eq 0 ]]; then
echo ""
return 1
fi
echo -e "\n${WIZARD_BOLD}${prompt}${WIZARD_NC}"
echo ""
# Display options
local i=1
for opt in "${options[@]}"; do
echo -e " ${WIZARD_CYAN}${i})${WIZARD_NC} ${opt}"
((i++))
done
echo ""
while true; do
echo -en "Select option [1-${num_options}]: "
read -r response
# Validate it's a number in range
if [[ "$response" =~ ^[0-9]+$ ]] && \
[[ "$response" -ge 1 ]] && \
[[ "$response" -le "$num_options" ]]; then
# Return the option text (0-indexed array)
echo "${options[$((response - 1))]}"
return 0
else
echo -e "${WIZARD_YELLOW}Please enter a number between 1 and ${num_options}${WIZARD_NC}"
fi
done
}
# select_multiple - Present checkboxes for multi-selection
#
# Parameters:
# $1 (prompt) - The question/prompt text
# $@ (options) - Remaining arguments are the options
#
# Outputs:
# Echoes comma-separated list of selected indices (0-based)
# Returns empty string if nothing selected
#
# Example:
# selected=$(select_multiple "Select task sources" "beads" "github" "prd")
# # If user selects first and third: selected="0,2"
# IFS=',' read -ra indices <<< "$selected"
# for idx in "${indices[@]}"; do
# echo "Selected: ${options[$idx]}"
# done
#
select_multiple() {
local prompt=$1
shift
local options=("$@")
local num_options=${#options[@]}
# Track selected state (0 = not selected, 1 = selected)
declare -a selected
for ((i = 0; i < num_options; i++)); do
selected[$i]=0
done
# Display instructions (redirect to stderr to avoid corrupting return value)
echo -e "\n${WIZARD_BOLD}${prompt}${WIZARD_NC}" >&2
echo -e "${WIZARD_CYAN}(Enter numbers to toggle, press Enter when done)${WIZARD_NC}" >&2
echo "" >&2
while true; do
# Display options with checkboxes
local i=1
for opt in "${options[@]}"; do
local checkbox="[ ]"
if [[ "${selected[$((i - 1))]}" == "1" ]]; then
checkbox="[${WIZARD_GREEN}x${WIZARD_NC}]"
fi
echo -e " ${WIZARD_CYAN}${i})${WIZARD_NC} ${checkbox} ${opt}" >&2
((i++)) || true
done
echo "" >&2
echo -en "Toggle [1-${num_options}] or Enter to confirm: " >&2
read -r response
# Empty input = done
if [[ -z "$response" ]]; then
break
fi
# Validate it's a number in range
if [[ "$response" =~ ^[0-9]+$ ]] && \
[[ "$response" -ge 1 ]] && \
[[ "$response" -le "$num_options" ]]; then
# Toggle the selection
local idx=$((response - 1))
if [[ "${selected[$idx]}" == "0" ]]; then
selected[$idx]=1
else
selected[$idx]=0
fi
else
echo -e "${WIZARD_YELLOW}Please enter a number between 1 and ${num_options}${WIZARD_NC}" >&2
fi
# Clear previous display (move cursor up)
# Number of lines to clear: options + 2 (prompt line + input line)
for ((j = 0; j < num_options + 2; j++)); do
echo -en "\033[A\033[K" >&2
done
done
# Build result string (comma-separated indices)
local result=""
for ((i = 0; i < num_options; i++)); do
if [[ "${selected[$i]}" == "1" ]]; then
if [[ -n "$result" ]]; then
result="$result,$i"
else
result="$i"
fi
fi
done
echo "$result"
}
# select_with_default - Present options with a recommended default
#
# Parameters:
# $1 (prompt) - The question/prompt text
# $2 (default_index) - 1-based index of default option
# $@ (options) - Remaining arguments are the options
#
# Outputs:
# Echoes the selected option
#
select_with_default() {
local prompt=$1
local default_index=$2
shift 2
local options=("$@")
local num_options=${#options[@]}
echo -e "\n${WIZARD_BOLD}${prompt}${WIZARD_NC}"
echo ""
# 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}"
else
echo -e " ${WIZARD_CYAN}${i})${WIZARD_NC} ${opt}"
fi
((i++))
done
echo ""
while true; do
echo -en "Select option [1-${num_options}] (default: ${default_index}): "
read -r response
# Use default if empty
if [[ -z "$response" ]]; then
echo "${options[$((default_index - 1))]}"
return 0
fi
# Validate it's a number in range
if [[ "$response" =~ ^[0-9]+$ ]] && \
[[ "$response" -ge 1 ]] && \
[[ "$response" -le "$num_options" ]]; then
echo "${options[$((response - 1))]}"
return 0
else
echo -e "${WIZARD_YELLOW}Please enter a number between 1 and ${num_options}${WIZARD_NC}"
fi
done
}
# =============================================================================
# DISPLAY UTILITIES
# =============================================================================
# print_header - Print a section header
#
# Parameters:
# $1 (title) - The header title
# $2 (phase) - Optional phase number (e.g., "1 of 5")
#
print_header() {
local title=$1
local phase="${2:-}"
echo ""
echo -e "${WIZARD_BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${WIZARD_NC}"
if [[ -n "$phase" ]]; then
echo -e "${WIZARD_BOLD} ${title}${WIZARD_NC} ${WIZARD_CYAN}(${phase})${WIZARD_NC}"
else
echo -e "${WIZARD_BOLD} ${title}${WIZARD_NC}"
fi
echo -e "${WIZARD_BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${WIZARD_NC}"
echo ""
}
# print_bullet - Print a bullet point item
#
# Parameters:
# $1 (text) - The text to display
# $2 (symbol) - Optional symbol (defaults to "•")
#
print_bullet() {
local text=$1
local symbol="${2:-}"
echo -e " ${WIZARD_CYAN}${symbol}${WIZARD_NC} ${text}"
}
# print_success - Print a success message
#
# Parameters:
# $1 (message) - The message to display
#
print_success() {
echo -e "${WIZARD_GREEN}${WIZARD_NC} $1"
}
# print_warning - Print a warning message
#
# Parameters:
# $1 (message) - The message to display
#
print_warning() {
echo -e "${WIZARD_YELLOW}${WIZARD_NC} $1"
}
# print_error - Print an error message
#
# Parameters:
# $1 (message) - The message to display
#
print_error() {
echo -e "${WIZARD_RED}${WIZARD_NC} $1"
}
# print_info - Print an info message
#
# Parameters:
# $1 (message) - The message to display
#
print_info() {
echo -e "${WIZARD_CYAN}${WIZARD_NC} $1"
}
# print_detection_result - Print a detection result with status
#
# Parameters:
# $1 (label) - What was detected
# $2 (value) - The detected value
# $3 (available) - "true" or "false"
#
print_detection_result() {
local label=$1
local value=$2
local available="${3:-true}"
if [[ "$available" == "true" ]]; then
echo -e " ${WIZARD_GREEN}${WIZARD_NC} ${label}: ${WIZARD_BOLD}${value}${WIZARD_NC}"
else
echo -e " ${WIZARD_YELLOW}${WIZARD_NC} ${label}: ${value}"
fi
}
# =============================================================================
# PROGRESS DISPLAY
# =============================================================================
# show_progress - Display a simple progress indicator
#
# Parameters:
# $1 (current) - Current step number
# $2 (total) - Total steps
# $3 (message) - Current step message
#
show_progress() {
local current=$1
local total=$2
local message=$3
local bar_width=30
local filled=$((current * bar_width / total))
local empty=$((bar_width - filled))
local bar=""
for ((i = 0; i < filled; i++)); do bar+="█"; done
for ((i = 0; i < empty; i++)); do bar+="░"; done
echo -en "\r${WIZARD_CYAN}[${bar}]${WIZARD_NC} ${current}/${total} ${message}"
}
# clear_line - Clear the current line
#
clear_line() {
echo -en "\r\033[K"
}
# =============================================================================
# SUMMARY DISPLAY
# =============================================================================
# print_summary - Print a summary box
#
# Parameters:
# $1 (title) - Summary title
# $@ (items) - Key=value pairs to display
#
# Example:
# print_summary "Configuration" "Project=my-app" "Type=typescript" "Tasks=15"
#
print_summary() {
local title=$1
shift
local items=("$@")
echo ""
echo -e "${WIZARD_BOLD}┌─ ${title} ───────────────────────────────────────┐${WIZARD_NC}"
echo "│"
for item in "${items[@]}"; do
local key="${item%%=*}"
local value="${item#*=}"
printf "${WIZARD_CYAN}%-20s${WIZARD_NC} %s\n" "${key}:" "$value"
done
echo "│"
echo -e "${WIZARD_BOLD}└────────────────────────────────────────────────────┘${WIZARD_NC}"
echo ""
}
# =============================================================================
# EXPORTS
# =============================================================================
export -f confirm
export -f prompt_text
export -f prompt_number
export -f select_option
export -f select_multiple
export -f select_with_default
export -f print_header
export -f print_bullet
export -f print_success
export -f print_warning
export -f print_error
export -f print_info
export -f print_detection_result
export -f show_progress
export -f clear_line
export -f print_summary