refactor(ux): consolidate BMAD skills, update design system, and clean up Prisma generated client

This commit is contained in:
Sepehr Ramezani
2026-04-19 19:21:27 +02:00
parent 5296c4da2c
commit 25529a24b8
2476 changed files with 127934 additions and 101962 deletions

View File

@@ -0,0 +1,490 @@
#!/bin/bash
# Circuit Breaker Component for Ralph
# Prevents runaway token consumption by detecting stagnation
# Based on Michael Nygard's "Release It!" pattern
# Source date utilities for cross-platform compatibility
source "$(dirname "${BASH_SOURCE[0]}")/date_utils.sh"
# Circuit Breaker States
CB_STATE_CLOSED="CLOSED" # Normal operation, progress detected
CB_STATE_HALF_OPEN="HALF_OPEN" # Monitoring mode, checking for recovery
CB_STATE_OPEN="OPEN" # Failure detected, execution halted
# Circuit Breaker Configuration
# Use RALPH_DIR if set by main script, otherwise default to .ralph
RALPH_DIR="${RALPH_DIR:-.ralph}"
CB_STATE_FILE="$RALPH_DIR/.circuit_breaker_state"
CB_HISTORY_FILE="$RALPH_DIR/.circuit_breaker_history"
# Configurable thresholds - override via environment variables:
# Example: CB_NO_PROGRESS_THRESHOLD=10 ralph --monitor
CB_NO_PROGRESS_THRESHOLD=${CB_NO_PROGRESS_THRESHOLD:-3} # Open circuit after N loops with no progress
CB_SAME_ERROR_THRESHOLD=${CB_SAME_ERROR_THRESHOLD:-5} # Open circuit after N loops with same error
CB_OUTPUT_DECLINE_THRESHOLD=${CB_OUTPUT_DECLINE_THRESHOLD:-70} # Open circuit if output declines by >70%
CB_PERMISSION_DENIAL_THRESHOLD=${CB_PERMISSION_DENIAL_THRESHOLD:-2} # Open circuit after N loops with permission denials (Issue #101)
CB_COOLDOWN_MINUTES=${CB_COOLDOWN_MINUTES:-30} # Minutes before OPEN → HALF_OPEN auto-recovery (Issue #160)
CB_AUTO_RESET=${CB_AUTO_RESET:-false} # Reset to CLOSED on startup instead of waiting for cooldown
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
# Initialize circuit breaker
init_circuit_breaker() {
# Check if state file exists and is valid JSON
if [[ -f "$CB_STATE_FILE" ]]; then
if ! jq '.' "$CB_STATE_FILE" > /dev/null 2>&1; then
# Corrupted, recreate
rm -f "$CB_STATE_FILE"
fi
fi
if [[ ! -f "$CB_STATE_FILE" ]]; then
jq -n \
--arg state "$CB_STATE_CLOSED" \
--arg last_change "$(get_iso_timestamp)" \
'{
state: $state,
last_change: $last_change,
consecutive_no_progress: 0,
consecutive_same_error: 0,
consecutive_permission_denials: 0,
last_progress_loop: 0,
total_opens: 0,
reason: ""
}' > "$CB_STATE_FILE"
fi
# Ensure history file exists before any transition logging
if [[ -f "$CB_HISTORY_FILE" ]]; then
if ! jq '.' "$CB_HISTORY_FILE" > /dev/null 2>&1; then
# Corrupted, recreate
rm -f "$CB_HISTORY_FILE"
fi
fi
if [[ ! -f "$CB_HISTORY_FILE" ]]; then
echo '[]' > "$CB_HISTORY_FILE"
fi
# Auto-recovery: check if OPEN state should transition (Issue #160)
local current_state
current_state=$(jq -r '.state' "$CB_STATE_FILE" 2>/dev/null || echo "$CB_STATE_CLOSED")
if [[ "$current_state" == "$CB_STATE_OPEN" ]]; then
if [[ "$CB_AUTO_RESET" == "true" ]]; then
# Auto-reset: bypass cooldown, go straight to CLOSED
local current_loop total_opens
current_loop=$(jq -r '.current_loop // 0' "$CB_STATE_FILE" 2>/dev/null || echo "0")
total_opens=$(jq -r '.total_opens // 0' "$CB_STATE_FILE" 2>/dev/null || echo "0")
log_circuit_transition "$CB_STATE_OPEN" "$CB_STATE_CLOSED" "Auto-reset on startup (CB_AUTO_RESET=true)" "$current_loop"
jq -n \
--arg state "$CB_STATE_CLOSED" \
--arg last_change "$(get_iso_timestamp)" \
--argjson total_opens "$total_opens" \
'{
state: $state,
last_change: $last_change,
consecutive_no_progress: 0,
consecutive_same_error: 0,
consecutive_permission_denials: 0,
last_progress_loop: 0,
total_opens: $total_opens,
reason: "Auto-reset on startup"
}' > "$CB_STATE_FILE"
else
# Cooldown: check if enough time has elapsed to transition to HALF_OPEN
local opened_at
opened_at=$(jq -r '.opened_at // .last_change // ""' "$CB_STATE_FILE" 2>/dev/null || echo "")
if [[ -n "$opened_at" && "$opened_at" != "null" ]]; then
local opened_epoch current_epoch elapsed_minutes
opened_epoch=$(parse_iso_to_epoch "$opened_at")
current_epoch=$(date +%s)
elapsed_minutes=$(( (current_epoch - opened_epoch) / 60 ))
if [[ $elapsed_minutes -ge 0 && $elapsed_minutes -ge $CB_COOLDOWN_MINUTES ]]; then
local current_loop
current_loop=$(jq -r '.current_loop // 0' "$CB_STATE_FILE" 2>/dev/null || echo "0")
log_circuit_transition "$CB_STATE_OPEN" "$CB_STATE_HALF_OPEN" "Cooldown elapsed (${elapsed_minutes}m >= ${CB_COOLDOWN_MINUTES}m)" "$current_loop"
# Preserve counters but transition state
local state_data
state_data=$(cat "$CB_STATE_FILE")
echo "$state_data" | jq \
--arg state "$CB_STATE_HALF_OPEN" \
--arg last_change "$(get_iso_timestamp)" \
--arg reason "Cooldown recovery: ${elapsed_minutes}m elapsed" \
'.state = $state | .last_change = $last_change | .reason = $reason' \
> "$CB_STATE_FILE"
fi
# If elapsed_minutes < 0 (clock skew), stay OPEN safely
fi
fi
fi
}
# Get current circuit breaker state
get_circuit_state() {
if [[ ! -f "$CB_STATE_FILE" ]]; then
echo "$CB_STATE_CLOSED"
return
fi
jq -r '.state' "$CB_STATE_FILE" 2>/dev/null || echo "$CB_STATE_CLOSED"
}
# Check if circuit breaker allows execution
can_execute() {
local state=$(get_circuit_state)
if [[ "$state" == "$CB_STATE_OPEN" ]]; then
return 1 # Circuit is open, cannot execute
else
return 0 # Circuit is closed or half-open, can execute
fi
}
# Record loop execution result
record_loop_result() {
local loop_number=$1
local files_changed=$2
local has_errors=$3
local output_length=$4
init_circuit_breaker
local state_data=$(cat "$CB_STATE_FILE")
local current_state=$(echo "$state_data" | jq -r '.state')
local consecutive_no_progress=$(echo "$state_data" | jq -r '.consecutive_no_progress' | tr -d '[:space:]')
local consecutive_same_error=$(echo "$state_data" | jq -r '.consecutive_same_error' | tr -d '[:space:]')
local consecutive_permission_denials=$(echo "$state_data" | jq -r '.consecutive_permission_denials // 0' | tr -d '[:space:]')
local last_progress_loop=$(echo "$state_data" | jq -r '.last_progress_loop' | tr -d '[:space:]')
# Ensure integers
consecutive_no_progress=$((consecutive_no_progress + 0))
consecutive_same_error=$((consecutive_same_error + 0))
consecutive_permission_denials=$((consecutive_permission_denials + 0))
last_progress_loop=$((last_progress_loop + 0))
# Detect progress from multiple sources:
# 1. Files changed (git diff)
# 2. Completion signal in response analysis (STATUS: COMPLETE or has_completion_signal)
# 3. Claude explicitly reported files modified in RALPH_STATUS block
local has_progress=false
local has_completion_signal=false
local ralph_files_modified=0
# Check response analysis file for completion signals and reported file changes
local response_analysis_file="$RALPH_DIR/.response_analysis"
if [[ -f "$response_analysis_file" ]]; then
# Read completion signal - STATUS: COMPLETE counts as progress even without git changes
has_completion_signal=$(jq -r '.analysis.has_completion_signal // false' "$response_analysis_file" 2>/dev/null || echo "false")
# Also check exit_signal (Claude explicitly signaling completion)
local exit_signal
exit_signal=$(jq -r '.analysis.exit_signal // false' "$response_analysis_file" 2>/dev/null || echo "false")
if [[ "$exit_signal" == "true" ]]; then
has_completion_signal="true"
fi
# Check if Claude reported files modified (may differ from git diff if already committed)
ralph_files_modified=$(jq -r '.analysis.files_modified // 0' "$response_analysis_file" 2>/dev/null || echo "0")
ralph_files_modified=$((ralph_files_modified + 0))
fi
# Track permission denials (Issue #101)
local has_permission_denials="false"
if [[ -f "$response_analysis_file" ]]; then
has_permission_denials=$(jq -r '.analysis.has_permission_denials // false' "$response_analysis_file" 2>/dev/null || echo "false")
fi
if [[ "${PERMISSION_DENIAL_MODE:-halt}" == "threshold" && "$has_permission_denials" == "true" ]]; then
consecutive_permission_denials=$((consecutive_permission_denials + 1))
else
consecutive_permission_denials=0
fi
# Determine if progress was made
if [[ $files_changed -gt 0 ]]; then
# Git shows uncommitted changes - clear progress
has_progress=true
consecutive_no_progress=0
last_progress_loop=$loop_number
elif [[ "$has_completion_signal" == "true" ]]; then
# Claude reported STATUS: COMPLETE - this is progress even without git changes
# (work may have been committed already, or Claude finished analyzing/planning)
has_progress=true
consecutive_no_progress=0
last_progress_loop=$loop_number
elif [[ $ralph_files_modified -gt 0 ]]; then
# Claude reported modifying files (may be committed already)
has_progress=true
consecutive_no_progress=0
last_progress_loop=$loop_number
else
consecutive_no_progress=$((consecutive_no_progress + 1))
fi
# Detect same error repetition
if [[ "$has_errors" == "true" ]]; then
consecutive_same_error=$((consecutive_same_error + 1))
else
consecutive_same_error=0
fi
# Determine new state and reason
local new_state="$current_state"
local reason=""
# State transitions
case $current_state in
"$CB_STATE_CLOSED")
# Normal operation - check for failure conditions
# Permission denials take highest priority (Issue #101)
if [[ $consecutive_permission_denials -ge $CB_PERMISSION_DENIAL_THRESHOLD ]]; then
new_state="$CB_STATE_OPEN"
reason="Permission denied in $consecutive_permission_denials consecutive loops"
elif [[ $consecutive_no_progress -ge $CB_NO_PROGRESS_THRESHOLD ]]; then
new_state="$CB_STATE_OPEN"
reason="No progress detected in $consecutive_no_progress consecutive loops"
elif [[ $consecutive_same_error -ge $CB_SAME_ERROR_THRESHOLD ]]; then
new_state="$CB_STATE_OPEN"
reason="Same error repeated in $consecutive_same_error consecutive loops"
elif [[ $consecutive_no_progress -ge 2 ]]; then
new_state="$CB_STATE_HALF_OPEN"
reason="Monitoring: $consecutive_no_progress loops without progress"
fi
;;
"$CB_STATE_HALF_OPEN")
# Monitoring mode - either recover or fail
# Permission denials take highest priority (Issue #101)
if [[ $consecutive_permission_denials -ge $CB_PERMISSION_DENIAL_THRESHOLD ]]; then
new_state="$CB_STATE_OPEN"
reason="Permission denied in $consecutive_permission_denials consecutive loops"
elif [[ "$has_progress" == "true" ]]; then
new_state="$CB_STATE_CLOSED"
reason="Progress detected, circuit recovered"
elif [[ $consecutive_no_progress -ge $CB_NO_PROGRESS_THRESHOLD ]]; then
new_state="$CB_STATE_OPEN"
reason="No recovery, opening circuit after $consecutive_no_progress loops"
fi
;;
"$CB_STATE_OPEN")
# Circuit is open - stays open (auto-recovery handled in init_circuit_breaker)
reason="Circuit breaker is open, execution halted"
;;
esac
# Update state file
local total_opens=$(echo "$state_data" | jq -r '.total_opens' | tr -d '[:space:]')
total_opens=$((total_opens + 0))
if [[ "$new_state" == "$CB_STATE_OPEN" && "$current_state" != "$CB_STATE_OPEN" ]]; then
total_opens=$((total_opens + 1))
fi
# Determine opened_at: set when entering OPEN, preserve when staying OPEN
local opened_at=""
if [[ "$new_state" == "$CB_STATE_OPEN" && "$current_state" != "$CB_STATE_OPEN" ]]; then
# Entering OPEN state - record the timestamp
opened_at=$(get_iso_timestamp)
elif [[ "$new_state" == "$CB_STATE_OPEN" && "$current_state" == "$CB_STATE_OPEN" ]]; then
# Staying OPEN - preserve existing opened_at (fall back to last_change for old state files)
opened_at=$(echo "$state_data" | jq -r '.opened_at // .last_change // ""' 2>/dev/null)
fi
jq -n \
--arg state "$new_state" \
--arg last_change "$(get_iso_timestamp)" \
--argjson consecutive_no_progress "$consecutive_no_progress" \
--argjson consecutive_same_error "$consecutive_same_error" \
--argjson consecutive_permission_denials "$consecutive_permission_denials" \
--argjson last_progress_loop "$last_progress_loop" \
--argjson total_opens "$total_opens" \
--arg reason "$reason" \
--argjson current_loop "$loop_number" \
'{
state: $state,
last_change: $last_change,
consecutive_no_progress: $consecutive_no_progress,
consecutive_same_error: $consecutive_same_error,
consecutive_permission_denials: $consecutive_permission_denials,
last_progress_loop: $last_progress_loop,
total_opens: $total_opens,
reason: $reason,
current_loop: $current_loop
}' > "$CB_STATE_FILE"
# Add opened_at if set (entering or staying in OPEN state)
if [[ -n "$opened_at" ]]; then
local tmp
tmp=$(jq --arg opened_at "$opened_at" '. + {opened_at: $opened_at}' "$CB_STATE_FILE")
echo "$tmp" > "$CB_STATE_FILE"
fi
# Log state transition
if [[ "$new_state" != "$current_state" ]]; then
log_circuit_transition "$current_state" "$new_state" "$reason" "$loop_number"
fi
# Return exit code based on new state
if [[ "$new_state" == "$CB_STATE_OPEN" ]]; then
return 1 # Circuit opened, signal to stop
else
return 0 # Can continue
fi
}
# Log circuit breaker state transitions
log_circuit_transition() {
local from_state=$1
local to_state=$2
local reason=$3
local loop_number=$4
local transition
transition=$(jq -n -c \
--arg timestamp "$(get_iso_timestamp)" \
--argjson loop "$loop_number" \
--arg from_state "$from_state" \
--arg to_state "$to_state" \
--arg reason "$reason" \
'{
timestamp: $timestamp,
loop: $loop,
from_state: $from_state,
to_state: $to_state,
reason: $reason
}')
local history
history=$(cat "$CB_HISTORY_FILE")
history=$(echo "$history" | jq ". += [$transition]")
echo "$history" > "$CB_HISTORY_FILE"
# Console log with colors
case $to_state in
"$CB_STATE_OPEN")
echo -e "${RED}🚨 CIRCUIT BREAKER OPENED${NC}"
echo -e "${RED}Reason: $reason${NC}"
;;
"$CB_STATE_HALF_OPEN")
echo -e "${YELLOW}⚠️ CIRCUIT BREAKER: Monitoring Mode${NC}"
echo -e "${YELLOW}Reason: $reason${NC}"
;;
"$CB_STATE_CLOSED")
echo -e "${GREEN}✅ CIRCUIT BREAKER: Normal Operation${NC}"
echo -e "${GREEN}Reason: $reason${NC}"
;;
esac
}
# Display circuit breaker status
show_circuit_status() {
init_circuit_breaker
local state_data=$(cat "$CB_STATE_FILE")
local state=$(echo "$state_data" | jq -r '.state')
local reason=$(echo "$state_data" | jq -r '.reason')
local no_progress=$(echo "$state_data" | jq -r '.consecutive_no_progress')
local last_progress=$(echo "$state_data" | jq -r '.last_progress_loop')
local current_loop=$(echo "$state_data" | jq -r '.current_loop')
local total_opens=$(echo "$state_data" | jq -r '.total_opens')
local color=""
local status_icon=""
case $state in
"$CB_STATE_CLOSED")
color=$GREEN
status_icon="✅"
;;
"$CB_STATE_HALF_OPEN")
color=$YELLOW
status_icon="⚠️ "
;;
"$CB_STATE_OPEN")
color=$RED
status_icon="🚨"
;;
esac
echo -e "${color}╔════════════════════════════════════════════════════════════╗${NC}"
echo -e "${color}║ Circuit Breaker Status ║${NC}"
echo -e "${color}╚════════════════════════════════════════════════════════════╝${NC}"
echo -e "${color}State:${NC} $status_icon $state"
echo -e "${color}Reason:${NC} $reason"
echo -e "${color}Loops since progress:${NC} $no_progress"
echo -e "${color}Last progress:${NC} Loop #$last_progress"
echo -e "${color}Current loop:${NC} #$current_loop"
echo -e "${color}Total opens:${NC} $total_opens"
echo ""
}
# Reset circuit breaker (for manual intervention)
reset_circuit_breaker() {
local reason=${1:-"Manual reset"}
jq -n \
--arg state "$CB_STATE_CLOSED" \
--arg last_change "$(get_iso_timestamp)" \
--arg reason "$reason" \
'{
state: $state,
last_change: $last_change,
consecutive_no_progress: 0,
consecutive_same_error: 0,
consecutive_permission_denials: 0,
last_progress_loop: 0,
total_opens: 0,
reason: $reason
}' > "$CB_STATE_FILE"
echo -e "${GREEN}✅ Circuit breaker reset to CLOSED state${NC}"
}
# Check if loop should halt (used in main loop)
should_halt_execution() {
local state=$(get_circuit_state)
if [[ "$state" == "$CB_STATE_OPEN" ]]; then
show_circuit_status
echo ""
echo -e "${RED}╔════════════════════════════════════════════════════════════╗${NC}"
echo -e "${RED}║ EXECUTION HALTED: Circuit Breaker Opened ║${NC}"
echo -e "${RED}╚════════════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "${YELLOW}Ralph has detected that no progress is being made.${NC}"
echo ""
echo -e "${YELLOW}Possible reasons:${NC}"
echo " • Project may be complete (check .ralph/@fix_plan.md)"
echo " • The active driver may be stuck on an error"
echo " • .ralph/PROMPT.md may need clarification"
echo " • Manual intervention may be required"
echo ""
echo -e "${YELLOW}To continue:${NC}"
echo " 1. Review recent logs: tail -20 .ralph/logs/ralph.log"
echo " 2. Check recent driver output: ls -lt .ralph/logs/claude_output_*.log | head -1"
echo " 3. Update .ralph/@fix_plan.md if needed"
echo " 4. Reset circuit breaker: bash .ralph/ralph_loop.sh --reset-circuit"
echo ""
return 0 # Signal to halt
else
return 1 # Can continue
fi
}
# Export functions
export -f init_circuit_breaker
export -f get_circuit_state
export -f can_execute
export -f record_loop_result
export -f show_circuit_status
export -f reset_circuit_breaker
export -f should_halt_execution

123
.ralph/lib/date_utils.sh Normal file
View File

@@ -0,0 +1,123 @@
#!/usr/bin/env bash
# date_utils.sh - Cross-platform date utility functions
# Provides consistent date formatting and arithmetic across GNU (Linux) and BSD (macOS) systems
# Get current timestamp in ISO 8601 format with seconds precision
# Returns: YYYY-MM-DDTHH:MM:SS+00:00 format
# Uses capability detection instead of uname to handle macOS with Homebrew coreutils
get_iso_timestamp() {
# Try GNU date first (works on Linux and macOS with Homebrew coreutils)
local result
if result=$(date -u -Iseconds 2>/dev/null) && [[ -n "$result" ]]; then
echo "$result"
return
fi
# Fallback to BSD date (native macOS) - add colon to timezone offset
date -u +"%Y-%m-%dT%H:%M:%S%z" | sed 's/\(..\)$/:\1/'
}
# Get time component (HH:MM:SS) for one hour from now
# Returns: HH:MM:SS format
# Uses capability detection instead of uname to handle macOS with Homebrew coreutils
get_next_hour_time() {
# Try GNU date first (works on Linux and macOS with Homebrew coreutils)
if date -d '+1 hour' '+%H:%M:%S' 2>/dev/null; then
return
fi
# Fallback to BSD date (native macOS)
if date -v+1H '+%H:%M:%S' 2>/dev/null; then
return
fi
# Ultimate fallback - compute using epoch arithmetic
local future_epoch=$(($(date +%s) + 3600))
date -r "$future_epoch" '+%H:%M:%S' 2>/dev/null || date '+%H:%M:%S'
}
# Get current timestamp in a basic format (fallback)
# Returns: YYYY-MM-DD HH:MM:SS format
get_basic_timestamp() {
date '+%Y-%m-%d %H:%M:%S'
}
# Get current Unix epoch time in seconds
# Returns: Integer seconds since 1970-01-01 00:00:00 UTC
get_epoch_seconds() {
date +%s
}
# Convert ISO 8601 timestamp to Unix epoch seconds
# Input: ISO timestamp (e.g., "2025-01-15T10:30:00+00:00")
# Returns: Unix epoch seconds on stdout
# Returns non-zero on parse failure.
parse_iso_to_epoch_strict() {
local iso_timestamp=$1
if [[ -z "$iso_timestamp" || "$iso_timestamp" == "null" ]]; then
return 1
fi
local normalized_iso
normalized_iso=$(printf '%s' "$iso_timestamp" | sed -E 's/\.([0-9]+)(Z|[+-][0-9]{2}:[0-9]{2})$/\2/')
# Try GNU date -d (Linux, macOS with Homebrew coreutils)
local result
if result=$(date -d "$iso_timestamp" +%s 2>/dev/null) && [[ "$result" =~ ^[0-9]+$ ]]; then
echo "$result"
return 0
fi
# Try BSD date -j (native macOS)
# Normalize timezone for BSD parsing (Z → +0000, ±HH:MM → ±HHMM)
local tz_fixed
tz_fixed=$(printf '%s' "$normalized_iso" | sed -E 's/Z$/+0000/; s/([+-][0-9]{2}):([0-9]{2})$/\1\2/')
if result=$(date -j -f "%Y-%m-%dT%H:%M:%S%z" "$tz_fixed" +%s 2>/dev/null) && [[ "$result" =~ ^[0-9]+$ ]]; then
echo "$result"
return 0
fi
# Fallback: manual epoch arithmetic from ISO components
# Parse: YYYY-MM-DDTHH:MM:SS (ignore timezone, assume UTC)
local year month day hour minute second
if [[ "$normalized_iso" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2}) ]]; then
year="${BASH_REMATCH[1]}"
month="${BASH_REMATCH[2]}"
day="${BASH_REMATCH[3]}"
hour="${BASH_REMATCH[4]}"
minute="${BASH_REMATCH[5]}"
second="${BASH_REMATCH[6]}"
# Use date with explicit components if available
if result=$(date -u -d "${year}-${month}-${day} ${hour}:${minute}:${second}" +%s 2>/dev/null) && [[ "$result" =~ ^[0-9]+$ ]]; then
echo "$result"
return 0
fi
fi
return 1
}
# Convert ISO 8601 timestamp to Unix epoch seconds
# Input: ISO timestamp (e.g., "2025-01-15T10:30:00+00:00")
# Returns: Unix epoch seconds on stdout
# Falls back to current epoch on parse failure (safe default)
parse_iso_to_epoch() {
local iso_timestamp=$1
local result
if result=$(parse_iso_to_epoch_strict "$iso_timestamp"); then
echo "$result"
return 0
fi
# Ultimate fallback: return current epoch (safe default)
date +%s
}
# Export functions for use in other scripts
export -f get_iso_timestamp
export -f get_next_hour_time
export -f get_basic_timestamp
export -f get_epoch_seconds
export -f parse_iso_to_epoch_strict
export -f parse_iso_to_epoch

820
.ralph/lib/enable_core.sh Normal file
View File

@@ -0,0 +1,820 @@
#!/usr/bin/env bash
# enable_core.sh - Shared logic for ralph enable commands
# Provides idempotency checks, safe file creation, and project detection
#
# Used by:
# - ralph_enable.sh (interactive wizard)
# - ralph_enable_ci.sh (non-interactive CI version)
# Exit codes - specific codes for different failure types
export ENABLE_SUCCESS=0 # Successful completion
export ENABLE_ERROR=1 # General error
export ENABLE_ALREADY_ENABLED=2 # Ralph already enabled (use --force)
export ENABLE_INVALID_ARGS=3 # Invalid command line arguments
export ENABLE_FILE_NOT_FOUND=4 # Required file not found (e.g., PRD file)
export ENABLE_DEPENDENCY_MISSING=5 # Required dependency missing (e.g., jq for --json)
export ENABLE_PERMISSION_DENIED=6 # Cannot create files/directories
# Colors (can be disabled for non-interactive mode)
export ENABLE_USE_COLORS="${ENABLE_USE_COLORS:-true}"
_color() {
if [[ "$ENABLE_USE_COLORS" == "true" ]]; then
echo -e "$1"
else
echo -e "$2"
fi
}
# Color codes
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'
# Logging function
enable_log() {
local level=$1
local message=$2
local color=""
case $level in
"INFO") color=$BLUE ;;
"WARN") color=$YELLOW ;;
"ERROR") color=$RED ;;
"SUCCESS") color=$GREEN ;;
"SKIP") color=$CYAN ;;
esac
if [[ "$ENABLE_USE_COLORS" == "true" ]]; then
echo -e "${color}[$level]${NC} $message"
else
echo "[$level] $message"
fi
}
# =============================================================================
# IDEMPOTENCY CHECKS
# =============================================================================
# check_existing_ralph - Check if .ralph directory exists and its state
#
# Returns:
# 0 - No .ralph directory, safe to proceed
# 1 - .ralph exists but incomplete (partial setup)
# 2 - .ralph exists and fully initialized
#
# Outputs:
# Sets global RALPH_STATE: "none" | "partial" | "complete"
# Sets global RALPH_MISSING_FILES: array of missing files if partial
#
check_existing_ralph() {
RALPH_STATE="none"
RALPH_MISSING_FILES=()
if [[ ! -d ".ralph" ]]; then
RALPH_STATE="none"
return 0
fi
# Check for required files
local required_files=(
".ralph/PROMPT.md"
".ralph/@fix_plan.md"
".ralph/@AGENT.md"
)
local missing=()
local found=0
for file in "${required_files[@]}"; do
if [[ -f "$file" ]]; then
found=$((found + 1))
else
missing+=("$file")
fi
done
RALPH_MISSING_FILES=("${missing[@]}")
if [[ $found -eq 0 ]]; then
RALPH_STATE="none"
return 0
elif [[ ${#missing[@]} -gt 0 ]]; then
RALPH_STATE="partial"
return 1
else
RALPH_STATE="complete"
return 2
fi
}
# is_ralph_enabled - Simple check if Ralph is fully enabled
#
# Returns:
# 0 - Ralph is fully enabled
# 1 - Ralph is not enabled or only partially
#
is_ralph_enabled() {
check_existing_ralph || true
[[ "$RALPH_STATE" == "complete" ]]
}
# =============================================================================
# SAFE FILE OPERATIONS
# =============================================================================
# safe_create_file - Create a file only if it doesn't exist (or force overwrite)
#
# Parameters:
# $1 (target) - Target file path
# $2 (content) - Content to write (can be empty string)
#
# Environment:
# ENABLE_FORCE - If "true", overwrites existing files instead of skipping
#
# Returns:
# 0 - File created/overwritten successfully
# 1 - File already exists (skipped, only when ENABLE_FORCE is not true)
# 2 - Error creating file
#
# Side effects:
# Logs [CREATE], [OVERWRITE], or [SKIP] message
#
safe_create_file() {
local target=$1
local content=$2
local force="${ENABLE_FORCE:-false}"
if [[ -f "$target" ]]; then
if [[ "$force" == "true" ]]; then
# Force mode: overwrite existing file
enable_log "INFO" "Overwriting $target (--force)"
else
# Normal mode: skip existing file
enable_log "SKIP" "$target already exists"
return 1
fi
fi
# Create parent directory if needed
local parent_dir
parent_dir=$(dirname "$target")
if [[ ! -d "$parent_dir" ]]; then
if ! mkdir -p "$parent_dir" 2>/dev/null; then
enable_log "ERROR" "Failed to create directory: $parent_dir"
return 2
fi
fi
# Write content to file using printf to avoid shell injection
# printf '%s\n' is safer than echo for arbitrary content (handles backslashes, -n, etc.)
if printf '%s\n' "$content" > "$target" 2>/dev/null; then
if [[ -f "$target" ]] && [[ "$force" == "true" ]]; then
enable_log "SUCCESS" "Overwrote $target"
else
enable_log "SUCCESS" "Created $target"
fi
return 0
else
enable_log "ERROR" "Failed to create: $target"
return 2
fi
}
# safe_create_dir - Create a directory only if it doesn't exist
#
# Parameters:
# $1 (target) - Target directory path
#
# Returns:
# 0 - Directory created or already exists
# 1 - Error creating directory
#
safe_create_dir() {
local target=$1
if [[ -d "$target" ]]; then
return 0
fi
if mkdir -p "$target" 2>/dev/null; then
enable_log "SUCCESS" "Created directory: $target"
return 0
else
enable_log "ERROR" "Failed to create directory: $target"
return 1
fi
}
# =============================================================================
# DIRECTORY STRUCTURE
# =============================================================================
# create_ralph_structure - Create the .ralph/ directory structure
#
# Creates:
# .ralph/
# .ralph/specs/
# .ralph/examples/
# .ralph/logs/
# .ralph/docs/generated/
#
# Returns:
# 0 - Structure created successfully
# 1 - Error creating structure
#
create_ralph_structure() {
local dirs=(
".ralph"
".ralph/specs"
".ralph/examples"
".ralph/logs"
".ralph/docs/generated"
)
for dir in "${dirs[@]}"; do
if ! safe_create_dir "$dir"; then
return 1
fi
done
return 0
}
# =============================================================================
# PROJECT DETECTION
# =============================================================================
# Exported detection results
export DETECTED_PROJECT_NAME=""
export DETECTED_PROJECT_TYPE=""
export DETECTED_FRAMEWORK=""
export DETECTED_BUILD_CMD=""
export DETECTED_TEST_CMD=""
export DETECTED_RUN_CMD=""
# detect_project_context - Detect project type, name, and build commands
#
# Detects:
# - Project type: javascript, typescript, python, rust, go, unknown
# - Framework: nextjs, fastapi, express, etc.
# - Build/test/run commands based on detected tooling
#
# Sets globals:
# DETECTED_PROJECT_NAME - Project name (from package.json, folder, etc.)
# DETECTED_PROJECT_TYPE - Language/type
# DETECTED_FRAMEWORK - Framework if detected
# DETECTED_BUILD_CMD - Build command
# DETECTED_TEST_CMD - Test command
# DETECTED_RUN_CMD - Run/start command
#
detect_project_context() {
# Reset detection results
DETECTED_PROJECT_NAME=""
DETECTED_PROJECT_TYPE="unknown"
DETECTED_FRAMEWORK=""
DETECTED_BUILD_CMD=""
DETECTED_TEST_CMD=""
DETECTED_RUN_CMD=""
# Detect from package.json (JavaScript/TypeScript)
if [[ -f "package.json" ]]; then
DETECTED_PROJECT_TYPE="javascript"
# Check for TypeScript
if grep -q '"typescript"' package.json 2>/dev/null || \
[[ -f "tsconfig.json" ]]; then
DETECTED_PROJECT_TYPE="typescript"
fi
# Extract project name
if command -v jq &>/dev/null; then
DETECTED_PROJECT_NAME=$(jq -r '.name // empty' package.json 2>/dev/null)
else
# Fallback: grep for name field
DETECTED_PROJECT_NAME=$(grep -m1 '"name"' package.json | sed 's/.*: *"\([^"]*\)".*/\1/' 2>/dev/null)
fi
# Detect framework
if grep -q '"next"' package.json 2>/dev/null; then
DETECTED_FRAMEWORK="nextjs"
elif grep -q '"express"' package.json 2>/dev/null; then
DETECTED_FRAMEWORK="express"
elif grep -q '"react"' package.json 2>/dev/null; then
DETECTED_FRAMEWORK="react"
elif grep -q '"vue"' package.json 2>/dev/null; then
DETECTED_FRAMEWORK="vue"
fi
# Set build commands
DETECTED_BUILD_CMD="npm run build"
DETECTED_TEST_CMD="npm test"
DETECTED_RUN_CMD="npm start"
# Check for yarn
if [[ -f "yarn.lock" ]]; then
DETECTED_BUILD_CMD="yarn build"
DETECTED_TEST_CMD="yarn test"
DETECTED_RUN_CMD="yarn start"
fi
# Check for pnpm
if [[ -f "pnpm-lock.yaml" ]]; then
DETECTED_BUILD_CMD="pnpm build"
DETECTED_TEST_CMD="pnpm test"
DETECTED_RUN_CMD="pnpm start"
fi
# Detect from pyproject.toml or setup.py (Python)
elif [[ -f "pyproject.toml" ]] || [[ -f "setup.py" ]]; then
DETECTED_PROJECT_TYPE="python"
# Extract project name from pyproject.toml
if [[ -f "pyproject.toml" ]]; then
DETECTED_PROJECT_NAME=$(grep -m1 '^name' pyproject.toml | sed 's/.*= *"\([^"]*\)".*/\1/' 2>/dev/null)
# Detect framework
if grep -q 'fastapi' pyproject.toml 2>/dev/null; then
DETECTED_FRAMEWORK="fastapi"
elif grep -q 'django' pyproject.toml 2>/dev/null; then
DETECTED_FRAMEWORK="django"
elif grep -q 'flask' pyproject.toml 2>/dev/null; then
DETECTED_FRAMEWORK="flask"
fi
fi
# Set build commands (prefer uv if detected)
if [[ -f "uv.lock" ]] || command -v uv &>/dev/null; then
DETECTED_BUILD_CMD="uv sync"
DETECTED_TEST_CMD="uv run pytest"
DETECTED_RUN_CMD="uv run python -m ${DETECTED_PROJECT_NAME:-main}"
else
DETECTED_BUILD_CMD="pip install -e ."
DETECTED_TEST_CMD="pytest"
DETECTED_RUN_CMD="python -m ${DETECTED_PROJECT_NAME:-main}"
fi
# Detect from Cargo.toml (Rust)
elif [[ -f "Cargo.toml" ]]; then
DETECTED_PROJECT_TYPE="rust"
DETECTED_PROJECT_NAME=$(grep -m1 '^name' Cargo.toml | sed 's/.*= *"\([^"]*\)".*/\1/' 2>/dev/null)
DETECTED_BUILD_CMD="cargo build"
DETECTED_TEST_CMD="cargo test"
DETECTED_RUN_CMD="cargo run"
# Detect from go.mod (Go)
elif [[ -f "go.mod" ]]; then
DETECTED_PROJECT_TYPE="go"
DETECTED_PROJECT_NAME=$(head -1 go.mod | sed 's/module //' 2>/dev/null)
DETECTED_BUILD_CMD="go build"
DETECTED_TEST_CMD="go test ./..."
DETECTED_RUN_CMD="go run ."
fi
# Fallback project name to folder name
if [[ -z "$DETECTED_PROJECT_NAME" ]]; then
DETECTED_PROJECT_NAME=$(basename "$(pwd)")
fi
}
# detect_git_info - Detect git repository information
#
# Sets globals:
# DETECTED_GIT_REPO - true if in git repo
# DETECTED_GIT_REMOTE - Remote URL (origin)
# DETECTED_GIT_GITHUB - true if GitHub remote
#
export DETECTED_GIT_REPO="false"
export DETECTED_GIT_REMOTE=""
export DETECTED_GIT_GITHUB="false"
detect_git_info() {
DETECTED_GIT_REPO="false"
DETECTED_GIT_REMOTE=""
DETECTED_GIT_GITHUB="false"
# Check if in git repo
if git rev-parse --git-dir &>/dev/null; then
DETECTED_GIT_REPO="true"
# Get remote URL
DETECTED_GIT_REMOTE=$(git remote get-url origin 2>/dev/null || echo "")
# Check if GitHub
if [[ "$DETECTED_GIT_REMOTE" == *"github.com"* ]]; then
DETECTED_GIT_GITHUB="true"
fi
fi
}
# detect_task_sources - Detect available task sources
#
# Sets globals:
# DETECTED_BEADS_AVAILABLE - true if .beads directory exists
# DETECTED_GITHUB_AVAILABLE - true if GitHub remote detected
# DETECTED_PRD_FILES - Array of potential PRD files found
#
export DETECTED_BEADS_AVAILABLE="false"
export DETECTED_GITHUB_AVAILABLE="false"
declare -a DETECTED_PRD_FILES=()
detect_task_sources() {
DETECTED_BEADS_AVAILABLE="false"
DETECTED_GITHUB_AVAILABLE="false"
DETECTED_PRD_FILES=()
# Check for beads
if [[ -d ".beads" ]]; then
DETECTED_BEADS_AVAILABLE="true"
fi
# Check for GitHub (reuse git detection)
detect_git_info
DETECTED_GITHUB_AVAILABLE="$DETECTED_GIT_GITHUB"
# Search for PRD/spec files
local search_dirs=("docs" "specs" "." "requirements")
local prd_patterns=("*prd*.md" "*PRD*.md" "*requirements*.md" "*spec*.md" "*specification*.md")
for dir in "${search_dirs[@]}"; do
if [[ -d "$dir" ]]; then
for pattern in "${prd_patterns[@]}"; do
while IFS= read -r -d '' file; do
DETECTED_PRD_FILES+=("$file")
done < <(find "$dir" -maxdepth 2 -name "$pattern" -print0 2>/dev/null)
done
fi
done
}
# =============================================================================
# TEMPLATE GENERATION
# =============================================================================
# get_templates_dir - Get the templates directory path
#
# Returns:
# Echoes the path to templates directory
# Returns 1 if not found
#
get_templates_dir() {
# Check global installation first
if [[ -d "$HOME/.ralph/templates" ]]; then
echo "$HOME/.ralph/templates"
return 0
fi
# Check local installation (development)
local script_dir
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ -d "$script_dir/../templates" ]]; then
echo "$script_dir/../templates"
return 0
fi
return 1
}
# generate_prompt_md - Generate PROMPT.md with project context
#
# Parameters:
# $1 (project_name) - Project name
# $2 (project_type) - Project type (typescript, python, etc.)
# $3 (framework) - Framework if any (optional)
# $4 (objectives) - Custom objectives (optional, newline-separated)
#
# Outputs to stdout
#
generate_prompt_md() {
local project_name="${1:-$(basename "$(pwd)")}"
local project_type="${2:-unknown}"
local framework="${3:-}"
local objectives="${4:-}"
local framework_line=""
if [[ -n "$framework" ]]; then
framework_line="**Framework:** $framework"
fi
local objectives_section=""
if [[ -n "$objectives" ]]; then
objectives_section="$objectives"
else
objectives_section="- Review the codebase and understand the current state
- Follow tasks in @fix_plan.md
- Implement one task per loop
- Write tests for new functionality
- Update documentation as needed"
fi
cat << PROMPTEOF
# Ralph Development Instructions
## Context
You are Ralph, an autonomous AI development agent working on the **${project_name}** project.
**Project Type:** ${project_type}
${framework_line}
## Current Objectives
${objectives_section}
## Key Principles
- ONE task per loop - focus on the most important thing
- Search the codebase before assuming something isn't implemented
- Write comprehensive tests with clear documentation
- Toggle completed story checkboxes in @fix_plan.md without rewriting story lines
- Commit working changes with descriptive messages
## Progress Tracking (CRITICAL)
- Ralph tracks progress by counting story checkboxes in @fix_plan.md
- When you complete a story, change \`- [ ]\` to \`- [x]\` on that exact story line
- Do NOT remove, rewrite, or reorder story lines in @fix_plan.md
- Update the checkbox before committing so the monitor updates immediately
- Set \`TASKS_COMPLETED_THIS_LOOP\` to the exact number of story checkboxes toggled this loop
- Only valid values: 0 or 1
## Testing Guidelines
- LIMIT testing to ~20% of your total effort per loop
- PRIORITIZE: Implementation > Documentation > Tests
- Only write tests for NEW functionality you implement
## Build & Run
See @AGENT.md for build and run instructions.
## Status Reporting (CRITICAL)
At the end of your response, ALWAYS include this status block:
\`\`\`
---RALPH_STATUS---
STATUS: IN_PROGRESS | COMPLETE | BLOCKED
TASKS_COMPLETED_THIS_LOOP: 0 | 1
FILES_MODIFIED: <number>
TESTS_STATUS: PASSING | FAILING | NOT_RUN
WORK_TYPE: IMPLEMENTATION | TESTING | DOCUMENTATION | REFACTORING
EXIT_SIGNAL: false | true
RECOMMENDATION: <one line summary of what to do next>
---END_RALPH_STATUS---
\`\`\`
## Current Task
Follow @fix_plan.md and choose the most important item to implement next.
PROMPTEOF
}
# generate_agent_md - Generate @AGENT.md with detected build commands
#
# Parameters:
# $1 (build_cmd) - Build command
# $2 (test_cmd) - Test command
# $3 (run_cmd) - Run command
#
# Outputs to stdout
#
generate_agent_md() {
local build_cmd="${1:-echo 'No build command configured'}"
local test_cmd="${2:-echo 'No test command configured'}"
local run_cmd="${3:-echo 'No run command configured'}"
cat << AGENTEOF
# Ralph Agent Configuration
## Build Instructions
\`\`\`bash
# Build the project
${build_cmd}
\`\`\`
## Test Instructions
\`\`\`bash
# Run tests
${test_cmd}
\`\`\`
## Run Instructions
\`\`\`bash
# Start/run the project
${run_cmd}
\`\`\`
## Notes
- Update this file when build process changes
- Add environment setup instructions as needed
- Include any pre-requisites or dependencies
AGENTEOF
}
# generate_fix_plan_md - Generate @fix_plan.md with imported tasks
#
# Parameters:
# $1 (tasks) - Tasks to include (newline-separated, markdown checkbox format)
#
# Outputs to stdout
#
generate_fix_plan_md() {
local tasks="${1:-}"
local high_priority=""
local medium_priority=""
local low_priority=""
if [[ -n "$tasks" ]]; then
high_priority="$tasks"
else
high_priority="- [ ] Review codebase and understand architecture
- [ ] Identify and document key components
- [ ] Set up development environment"
medium_priority="- [ ] Implement core features
- [ ] Add test coverage
- [ ] Update documentation"
low_priority="- [ ] Performance optimization
- [ ] Code cleanup and refactoring"
fi
cat << FIXPLANEOF
# Ralph Fix Plan
## High Priority
${high_priority}
## Medium Priority
${medium_priority}
## Low Priority
${low_priority}
## Completed
- [x] Project enabled for Ralph
## Notes
- Focus on MVP functionality first
- Ensure each feature is properly tested
- Update this file after each major milestone
FIXPLANEOF
}
# generate_ralphrc - Generate .ralphrc configuration file
#
# Parameters:
# $1 (project_name) - Project name
# $2 (project_type) - Project type
# $3 (task_sources) - Task sources (local, beads, github)
#
# Outputs to stdout
#
generate_ralphrc() {
local project_name="${1:-$(basename "$(pwd)")}"
local project_type="${2:-unknown}"
local task_sources="${3:-local}"
cat << RALPHRCEOF
# .ralphrc - Ralph project configuration
# Generated by: ralph enable
# Documentation: https://github.com/frankbria/ralph-claude-code
# Project identification
PROJECT_NAME="${project_name}"
PROJECT_TYPE="${project_type}"
# Loop settings
MAX_CALLS_PER_HOUR=100
CLAUDE_TIMEOUT_MINUTES=15
CLAUDE_OUTPUT_FORMAT="json"
# Tool permissions
# Comma-separated list of allowed tools
ALLOWED_TOOLS="Write,Read,Edit,Bash(git *),Bash(npm *),Bash(pytest)"
# Session management
SESSION_CONTINUITY=true
SESSION_EXPIRY_HOURS=24
# Task sources (for ralph enable --sync)
# Options: local, beads, github (comma-separated for multiple)
TASK_SOURCES="${task_sources}"
GITHUB_TASK_LABEL="ralph-task"
BEADS_FILTER="status:open"
# Circuit breaker thresholds
CB_NO_PROGRESS_THRESHOLD=3
CB_SAME_ERROR_THRESHOLD=5
CB_OUTPUT_DECLINE_THRESHOLD=70
RALPHRCEOF
}
# =============================================================================
# MAIN ENABLE LOGIC
# =============================================================================
# enable_ralph_in_directory - Main function to enable Ralph in current directory
#
# Parameters:
# $1 (options) - JSON-like options string or empty
# force: true/false - Force overwrite existing
# skip_tasks: true/false - Skip task import
# project_name: string - Override project name
# task_content: string - Pre-imported task content
#
# Returns:
# 0 - Success
# 1 - Error
# 2 - Already enabled (and no force flag)
#
enable_ralph_in_directory() {
local force="${ENABLE_FORCE:-false}"
local skip_tasks="${ENABLE_SKIP_TASKS:-false}"
local project_name="${ENABLE_PROJECT_NAME:-}"
local project_type="${ENABLE_PROJECT_TYPE:-}"
local task_content="${ENABLE_TASK_CONTENT:-}"
# Check existing state (use || true to prevent set -e from exiting)
check_existing_ralph || true
if [[ "$RALPH_STATE" == "complete" && "$force" != "true" ]]; then
enable_log "INFO" "Ralph is already enabled in this project"
enable_log "INFO" "Use --force to overwrite existing configuration"
return $ENABLE_ALREADY_ENABLED
fi
# Detect project context
detect_project_context
# Use detected or provided project name
if [[ -z "$project_name" ]]; then
project_name="$DETECTED_PROJECT_NAME"
fi
# Use detected or provided project type
if [[ -n "$project_type" ]]; then
DETECTED_PROJECT_TYPE="$project_type"
fi
enable_log "INFO" "Enabling Ralph for: $project_name"
enable_log "INFO" "Project type: $DETECTED_PROJECT_TYPE"
if [[ -n "$DETECTED_FRAMEWORK" ]]; then
enable_log "INFO" "Framework: $DETECTED_FRAMEWORK"
fi
# Create directory structure
if ! create_ralph_structure; then
enable_log "ERROR" "Failed to create .ralph/ structure"
return $ENABLE_ERROR
fi
# Generate and create files
local prompt_content
prompt_content=$(generate_prompt_md "$project_name" "$DETECTED_PROJECT_TYPE" "$DETECTED_FRAMEWORK")
safe_create_file ".ralph/PROMPT.md" "$prompt_content"
local agent_content
agent_content=$(generate_agent_md "$DETECTED_BUILD_CMD" "$DETECTED_TEST_CMD" "$DETECTED_RUN_CMD")
safe_create_file ".ralph/@AGENT.md" "$agent_content"
local fix_plan_content
fix_plan_content=$(generate_fix_plan_md "$task_content")
safe_create_file ".ralph/@fix_plan.md" "$fix_plan_content"
# Detect task sources for .ralphrc
detect_task_sources
local task_sources="local"
if [[ "$DETECTED_BEADS_AVAILABLE" == "true" ]]; then
task_sources="beads,$task_sources"
fi
if [[ "$DETECTED_GITHUB_AVAILABLE" == "true" ]]; then
task_sources="github,$task_sources"
fi
# Generate .ralphrc
local ralphrc_content
ralphrc_content=$(generate_ralphrc "$project_name" "$DETECTED_PROJECT_TYPE" "$task_sources")
safe_create_file ".ralphrc" "$ralphrc_content"
enable_log "SUCCESS" "Ralph enabled successfully!"
return $ENABLE_SUCCESS
}
# Export functions for use in other scripts
export -f enable_log
export -f check_existing_ralph
export -f is_ralph_enabled
export -f safe_create_file
export -f safe_create_dir
export -f create_ralph_structure
export -f detect_project_context
export -f detect_git_info
export -f detect_task_sources
export -f get_templates_dir
export -f generate_prompt_md
export -f generate_agent_md
export -f generate_fix_plan_md
export -f generate_ralphrc
export -f enable_ralph_in_directory

File diff suppressed because it is too large Load Diff

611
.ralph/lib/task_sources.sh Normal file
View File

@@ -0,0 +1,611 @@
#!/usr/bin/env bash
# task_sources.sh - Task import utilities for Ralph enable
# Supports importing tasks from beads, GitHub Issues, and PRD files
# =============================================================================
# BEADS INTEGRATION
# =============================================================================
# check_beads_available - Check if beads (bd) is available and configured
#
# Returns:
# 0 - Beads available
# 1 - Beads not available or not configured
#
check_beads_available() {
# Check for .beads directory
if [[ ! -d ".beads" ]]; then
return 1
fi
# Check if bd command exists
if ! command -v bd &>/dev/null; then
return 1
fi
return 0
}
# fetch_beads_tasks - Fetch tasks from beads issue tracker
#
# Parameters:
# $1 (filterStatus) - Status filter (optional, default: "open")
#
# Outputs:
# Tasks in markdown checkbox format, one per line
# e.g., "- [ ] [issue-001] Fix authentication bug"
#
# Returns:
# 0 - Success (may output empty if no tasks)
# 1 - Error fetching tasks
#
fetch_beads_tasks() {
local filterStatus="${1:-open}"
local tasks=""
# Check if beads is available
if ! check_beads_available; then
return 1
fi
# Build bd list command arguments
local bdArgs=("list" "--json")
if [[ "$filterStatus" == "open" ]]; then
bdArgs+=("--status" "open")
elif [[ "$filterStatus" == "in_progress" ]]; then
bdArgs+=("--status" "in_progress")
elif [[ "$filterStatus" == "all" ]]; then
bdArgs+=("--all")
fi
# Try to get tasks as JSON
local json_output
if json_output=$(bd "${bdArgs[@]}" 2>/dev/null); then
# Parse JSON and format as markdown tasks
# Note: Use 'select(.status == "closed") | not' to avoid bash escaping issues with '!='
# Also filter out entries with missing id or title fields
if command -v jq &>/dev/null; then
tasks=$(echo "$json_output" | jq -r '
.[] |
select(.status == "closed" | not) |
select((.id // "") != "" and (.title // "") != "") |
"- [ ] [\(.id)] \(.title)"
' 2>/dev/null || echo "")
fi
fi
# Fallback: try plain text output if JSON failed or produced no results
if [[ -z "$tasks" ]]; then
# Build fallback args (reuse status logic, but without --json)
local fallbackArgs=("list")
if [[ "$filterStatus" == "open" ]]; then
fallbackArgs+=("--status" "open")
elif [[ "$filterStatus" == "in_progress" ]]; then
fallbackArgs+=("--status" "in_progress")
elif [[ "$filterStatus" == "all" ]]; then
fallbackArgs+=("--all")
fi
tasks=$(bd "${fallbackArgs[@]}" 2>/dev/null | while IFS= read -r line; do
# Extract ID and title from bd list output
# Format: "○ cnzb-xxx [● P2] [task] - Title here"
local id title
id=$(echo "$line" | grep -oE '[a-z]+-[a-z0-9]+' | head -1 || echo "")
# Extract title after the last " - " separator
title=$(echo "$line" | sed 's/.*- //' || echo "$line")
if [[ -n "$id" && -n "$title" ]]; then
echo "- [ ] [$id] $title"
fi
done)
fi
if [[ -n "$tasks" ]]; then
echo "$tasks"
return 0
else
return 0 # Empty is not an error
fi
}
# get_beads_count - Get count of open beads issues
#
# Returns:
# 0 and echoes the count
# 1 if beads unavailable
#
get_beads_count() {
if ! check_beads_available; then
echo "0"
return 1
fi
local count
if command -v jq &>/dev/null; then
# Note: Use 'select(.status == "closed" | not)' to avoid bash escaping issues with '!='
count=$(bd list --json 2>/dev/null | jq '[.[] | select(.status == "closed" | not)] | length' 2>/dev/null || echo "0")
else
count=$(bd list 2>/dev/null | wc -l | tr -d ' ')
fi
echo "${count:-0}"
return 0
}
# =============================================================================
# GITHUB ISSUES INTEGRATION
# =============================================================================
# check_github_available - Check if GitHub CLI (gh) is available and authenticated
#
# Returns:
# 0 - GitHub available and authenticated
# 1 - Not available
#
check_github_available() {
# Check for gh command
if ! command -v gh &>/dev/null; then
return 1
fi
# Check if authenticated
if ! gh auth status &>/dev/null; then
return 1
fi
# Check if in a git repo with GitHub remote
if ! git remote get-url origin 2>/dev/null | grep -q "github.com"; then
return 1
fi
return 0
}
# fetch_github_tasks - Fetch issues from GitHub
#
# Parameters:
# $1 (label) - Label to filter by (optional, default: "ralph-task")
# $2 (limit) - Maximum number of issues (optional, default: 50)
#
# Outputs:
# Tasks in markdown checkbox format
# e.g., "- [ ] [#123] Implement user authentication"
#
# Returns:
# 0 - Success
# 1 - Error
#
fetch_github_tasks() {
local label="${1:-}"
local limit="${2:-50}"
local tasks=""
# Check if GitHub is available
if ! check_github_available; then
return 1
fi
# Build gh command
local gh_args=("issue" "list" "--state" "open" "--limit" "$limit" "--json" "number,title,labels")
if [[ -n "$label" ]]; then
gh_args+=("--label" "$label")
fi
# Fetch issues
local json_output
if ! json_output=$(gh "${gh_args[@]}" 2>/dev/null); then
return 1
fi
# Parse JSON and format as markdown tasks
if command -v jq &>/dev/null; then
tasks=$(echo "$json_output" | jq -r '
.[] |
"- [ ] [#\(.number)] \(.title)"
' 2>/dev/null)
fi
if [[ -n "$tasks" ]]; then
echo "$tasks"
fi
return 0
}
# get_github_issue_count - Get count of open GitHub issues
#
# Parameters:
# $1 (label) - Label to filter by (optional)
#
# Returns:
# 0 and echoes the count
# 1 if GitHub unavailable
#
get_github_issue_count() {
local label="${1:-}"
if ! check_github_available; then
echo "0"
return 1
fi
local gh_args=("issue" "list" "--state" "open" "--json" "number")
if [[ -n "$label" ]]; then
gh_args+=("--label" "$label")
fi
local count
if command -v jq &>/dev/null; then
count=$(gh "${gh_args[@]}" 2>/dev/null | jq 'length' 2>/dev/null || echo "0")
else
count=$(gh issue list --state open 2>/dev/null | wc -l | tr -d ' ')
fi
echo "${count:-0}"
return 0
}
# get_github_labels - Get available labels from GitHub repo
#
# Outputs:
# Newline-separated list of label names
#
get_github_labels() {
if ! check_github_available; then
return 1
fi
gh label list --json name --jq '.[].name' 2>/dev/null
}
# =============================================================================
# PRD CONVERSION
# =============================================================================
# extract_prd_tasks - Extract tasks from a PRD/specification document
#
# Parameters:
# $1 (prd_file) - Path to the PRD file
#
# Outputs:
# Tasks in markdown checkbox format
#
# Returns:
# 0 - Success
# 1 - Error
#
# Note: For full PRD conversion with Claude, use ralph-import
# This function does basic extraction without AI assistance
#
extract_prd_tasks() {
local prd_file=$1
if [[ ! -f "$prd_file" ]]; then
return 1
fi
local tasks=""
# Look for existing checkbox items
local checkbox_tasks
checkbox_tasks=$(grep -E '^[[:space:]]*[-*][[:space:]]*\[[[:space:]]*[xX ]?[[:space:]]*\]' "$prd_file" 2>/dev/null)
if [[ -n "$checkbox_tasks" ]]; then
# Normalize to unchecked format
tasks=$(echo "$checkbox_tasks" | sed 's/\[x\]/[ ]/gi; s/\[X\]/[ ]/g')
fi
# Look for numbered list items that look like tasks
local numbered_tasks
numbered_tasks=$(grep -E '^[[:space:]]*[0-9]+\.[[:space:]]+' "$prd_file" 2>/dev/null | head -20)
if [[ -n "$numbered_tasks" ]]; then
while IFS= read -r line; do
# Convert numbered item to checkbox
local task_text
task_text=$(echo "$line" | sed -E 's/^[[:space:]]*[0-9]*\.[[:space:]]*//')
if [[ -n "$task_text" ]]; then
tasks="${tasks}
- [ ] ${task_text}"
fi
done <<< "$numbered_tasks"
fi
# Look for headings that might be task sections (with line numbers to handle duplicates)
local heading_lines
heading_lines=$(grep -nE '^#{1,3}[[:space:]]+(TODO|Tasks|Requirements|Features|Backlog|Sprint)' "$prd_file" 2>/dev/null)
if [[ -n "$heading_lines" ]]; then
# Extract bullet items beneath each matching heading
while IFS= read -r heading_entry; do
# Parse line number directly from grep -n output (avoids duplicate heading issue)
local heading_line
heading_line=$(echo "$heading_entry" | cut -d: -f1)
[[ -z "$heading_line" ]] && continue
# Find the next heading (any level) after this one
local next_heading_line
next_heading_line=$(tail -n +"$((heading_line + 1))" "$prd_file" | grep -n '^#' | head -1 | cut -d: -f1)
# Extract the section content
local section_content
if [[ -n "$next_heading_line" ]]; then
local end_line=$((heading_line + next_heading_line - 1))
section_content=$(sed -n "$((heading_line + 1)),${end_line}p" "$prd_file")
else
section_content=$(tail -n +"$((heading_line + 1))" "$prd_file")
fi
# Extract bullet items from section and convert to checkboxes
while IFS= read -r line; do
local task_text=""
# Match "- item" or "* item" (but not checkboxes, already handled above)
if [[ "$line" =~ ^[[:space:]]*[-*][[:space:]]+(.+)$ ]]; then
task_text="${BASH_REMATCH[1]}"
# Skip checkbox lines — they are handled by the earlier extraction
if [[ "$line" == *"["*"]"* ]]; then
task_text=""
fi
# Match "N. item" numbered patterns
elif [[ "$line" =~ ^[[:space:]]*[0-9]+\.[[:space:]]+(.+)$ ]]; then
task_text="${BASH_REMATCH[1]}"
fi
if [[ -n "$task_text" ]]; then
tasks="${tasks}
- [ ] ${task_text}"
fi
done <<< "$section_content"
done <<< "$heading_lines"
fi
# Clean up and output
if [[ -n "$tasks" ]]; then
echo "$tasks" | grep -v '^$' | awk '!seen[$0]++' | head -30 # Deduplicate, limit to 30
return 0
fi
return 0 # Empty is not an error
}
# convert_prd_with_claude - Full PRD conversion using Claude (calls ralph-import logic)
#
# Parameters:
# $1 (prd_file) - Path to the PRD file
# $2 (output_dir) - Directory to output converted files (optional, defaults to .ralph/)
#
# Outputs:
# Sets CONVERTED_PROMPT_FILE, CONVERTED_FIX_PLAN_FILE, CONVERTED_SPECS_FILE
#
# Returns:
# 0 - Success
# 1 - Error
#
convert_prd_with_claude() {
local prd_file=$1
local output_dir="${2:-.ralph}"
# This would call into ralph_import.sh's convert_prd function
# For now, we do basic extraction
# Full Claude-based conversion requires the import script
if [[ ! -f "$prd_file" ]]; then
return 1
fi
# Check if ralph-import is available for full conversion
if command -v ralph-import &>/dev/null; then
# Use ralph-import for full conversion
# Note: ralph-import creates a new project, so we need to adapt
echo "Full PRD conversion available via: ralph-import $prd_file"
return 1 # Return error to indicate basic extraction should be used
fi
# Fall back to basic extraction
extract_prd_tasks "$prd_file"
}
# =============================================================================
# TASK NORMALIZATION
# =============================================================================
# normalize_tasks - Normalize tasks to consistent markdown format
#
# Parameters:
# $1 (tasks) - Raw task text (multi-line)
# $2 (source) - Source identifier (beads, github, prd)
#
# Outputs:
# Normalized tasks in markdown checkbox format
#
normalize_tasks() {
local tasks=$1
local source="${2:-unknown}"
if [[ -z "$tasks" ]]; then
return 0
fi
# Process each line
echo "$tasks" | while IFS= read -r line; do
# Skip empty lines
[[ -z "$line" ]] && continue
# Already in checkbox format
if echo "$line" | grep -qE '^[[:space:]]*-[[:space:]]*\[[[:space:]]*[xX ]?[[:space:]]*\]'; then
# Normalize the checkbox
echo "$line" | sed 's/\[x\]/[ ]/gi; s/\[X\]/[ ]/g'
continue
fi
# Bullet point without checkbox
if echo "$line" | grep -qE '^[[:space:]]*[-*][[:space:]]+'; then
local text
text=$(echo "$line" | sed -E 's/^[[:space:]]*[-*][[:space:]]*//')
echo "- [ ] $text"
continue
fi
# Numbered item
if echo "$line" | grep -qE '^[[:space:]]*[0-9]+\.?[[:space:]]+'; then
local text
text=$(echo "$line" | sed -E 's/^[[:space:]]*[0-9]*\.?[[:space:]]*//')
echo "- [ ] $text"
continue
fi
# Plain text line - make it a task
echo "- [ ] $line"
done
}
# prioritize_tasks - Sort tasks by priority heuristics
#
# Parameters:
# $1 (tasks) - Tasks in markdown format
#
# Outputs:
# Tasks sorted with priority indicators
#
# Heuristics:
# - "critical", "urgent", "blocker" -> High priority
# - "important", "should", "must" -> High priority
# - "nice to have", "optional", "future" -> Low priority
#
prioritize_tasks() {
local tasks=$1
if [[ -z "$tasks" ]]; then
return 0
fi
# Separate into priority buckets
local high_priority=""
local medium_priority=""
local low_priority=""
while IFS= read -r line; do
[[ -z "$line" ]] && continue
local lower_line
lower_line=$(echo "$line" | tr '[:upper:]' '[:lower:]')
# Check for priority indicators
if echo "$lower_line" | grep -qE '(critical|urgent|blocker|breaking|security|p0|p1)'; then
high_priority="${high_priority}${line}
"
elif echo "$lower_line" | grep -qE '(nice.to.have|optional|future|later|p3|p4|low.priority)'; then
low_priority="${low_priority}${line}
"
elif echo "$lower_line" | grep -qE '(important|should|must|needed|required|p2)'; then
high_priority="${high_priority}${line}
"
else
medium_priority="${medium_priority}${line}
"
fi
done <<< "$tasks"
# Output in priority order
echo "## High Priority"
[[ -n "$high_priority" ]] && echo "$high_priority"
echo ""
echo "## Medium Priority"
[[ -n "$medium_priority" ]] && echo "$medium_priority"
echo ""
echo "## Low Priority"
[[ -n "$low_priority" ]] && echo "$low_priority"
}
# =============================================================================
# COMBINED IMPORT
# =============================================================================
# import_tasks_from_sources - Import tasks from multiple sources
#
# Parameters:
# $1 (sources) - Space-separated list of sources: beads, github, prd
# $2 (prd_file) - Path to PRD file (required if prd in sources)
# $3 (github_label) - GitHub label filter (optional)
#
# Outputs:
# Combined tasks in markdown format
#
# Returns:
# 0 - Success
# 1 - No tasks imported
#
import_tasks_from_sources() {
local sources=$1
local prd_file="${2:-}"
local github_label="${3:-}"
local all_tasks=""
local source_count=0
# Import from beads
if echo "$sources" | grep -qw "beads"; then
local beads_tasks
if beads_tasks=$(fetch_beads_tasks); then
if [[ -n "$beads_tasks" ]]; then
all_tasks="${all_tasks}
# Tasks from beads
${beads_tasks}
"
((source_count++))
fi
fi
fi
# Import from GitHub
if echo "$sources" | grep -qw "github"; then
local github_tasks
if github_tasks=$(fetch_github_tasks "$github_label"); then
if [[ -n "$github_tasks" ]]; then
all_tasks="${all_tasks}
# Tasks from GitHub
${github_tasks}
"
((source_count++))
fi
fi
fi
# Import from PRD
if echo "$sources" | grep -qw "prd"; then
if [[ -n "$prd_file" && -f "$prd_file" ]]; then
local prd_tasks
if prd_tasks=$(extract_prd_tasks "$prd_file"); then
if [[ -n "$prd_tasks" ]]; then
all_tasks="${all_tasks}
# Tasks from PRD
${prd_tasks}
"
((source_count++))
fi
fi
fi
fi
if [[ -z "$all_tasks" ]]; then
return 1
fi
# Normalize and output
normalize_tasks "$all_tasks" "combined"
return 0
}
# =============================================================================
# EXPORTS
# =============================================================================
export -f check_beads_available
export -f fetch_beads_tasks
export -f get_beads_count
export -f check_github_available
export -f fetch_github_tasks
export -f get_github_issue_count
export -f get_github_labels
export -f extract_prd_tasks
export -f convert_prd_with_claude
export -f normalize_tasks
export -f prioritize_tasks
export -f import_tasks_from_sources

145
.ralph/lib/timeout_utils.sh Normal file
View File

@@ -0,0 +1,145 @@
#!/usr/bin/env bash
# timeout_utils.sh - Cross-platform timeout utility functions
# Provides consistent timeout command execution across GNU (Linux) and BSD (macOS) systems
#
# On Linux: Uses the built-in GNU `timeout` command from coreutils
# On macOS: Uses `gtimeout` from Homebrew coreutils, or falls back to `timeout` if available
# Cached timeout command to avoid repeated detection
export _TIMEOUT_CMD=""
# Detect the available timeout command for this platform
# Sets _TIMEOUT_CMD to the appropriate command
# Returns 0 if a timeout command is available, 1 if not
detect_timeout_command() {
# Return cached result if already detected
if [[ -n "$_TIMEOUT_CMD" ]]; then
echo "$_TIMEOUT_CMD"
return 0
fi
local os_type
os_type=$(uname)
if [[ "$os_type" == "Darwin" ]]; then
# macOS: Check for gtimeout (from Homebrew coreutils) first
if command -v gtimeout &> /dev/null; then
_TIMEOUT_CMD="gtimeout"
elif command -v timeout &> /dev/null; then
# Some macOS setups might have timeout available (e.g., MacPorts)
_TIMEOUT_CMD="timeout"
else
# No timeout command available
_TIMEOUT_CMD=""
return 1
fi
else
# Linux and other Unix systems: use standard timeout
if command -v timeout &> /dev/null; then
_TIMEOUT_CMD="timeout"
else
# Timeout not found (unusual on Linux)
_TIMEOUT_CMD=""
return 1
fi
fi
echo "$_TIMEOUT_CMD"
return 0
}
# Check if a timeout command is available on this system
# Returns 0 if available, 1 if not
has_timeout_command() {
local cmd
cmd=$(detect_timeout_command 2>/dev/null)
[[ -n "$cmd" ]]
}
# Get a user-friendly message about timeout availability
# Useful for error messages and installation instructions
get_timeout_status_message() {
local os_type
os_type=$(uname)
if has_timeout_command; then
local cmd
cmd=$(detect_timeout_command)
echo "Timeout command available: $cmd"
return 0
fi
if [[ "$os_type" == "Darwin" ]]; then
echo "Timeout command not found. Install GNU coreutils: brew install coreutils"
else
echo "Timeout command not found. Install coreutils: sudo apt-get install coreutils"
fi
return 1
}
# Execute a command with a timeout (cross-platform)
# Usage: portable_timeout DURATION COMMAND [ARGS...]
#
# Arguments:
# DURATION - Timeout duration (e.g., "30s", "5m", "1h")
# COMMAND - The command to execute
# ARGS - Additional arguments for the command
#
# Returns:
# 0 - Command completed successfully within timeout
# 124 - Command timed out (GNU timeout behavior)
# 1 - No timeout command available (logs error)
# * - Exit code from the executed command
#
# Example:
# portable_timeout 30s curl -s https://example.com
# portable_timeout 5m npm install
#
portable_timeout() {
local duration=$1
shift
# Validate arguments
if [[ -z "$duration" ]]; then
echo "Error: portable_timeout requires a duration argument" >&2
return 1
fi
if [[ $# -eq 0 ]]; then
echo "Error: portable_timeout requires a command to execute" >&2
return 1
fi
# Detect the timeout command
local timeout_cmd
timeout_cmd=$(detect_timeout_command 2>/dev/null)
if [[ -z "$timeout_cmd" ]]; then
local os_type
os_type=$(uname)
echo "Error: No timeout command available on this system" >&2
if [[ "$os_type" == "Darwin" ]]; then
echo "Install GNU coreutils on macOS: brew install coreutils" >&2
else
echo "Install coreutils: sudo apt-get install coreutils" >&2
fi
return 1
fi
# Execute the command with timeout
"$timeout_cmd" "$duration" "$@"
}
# Reset the cached timeout command (useful for testing)
reset_timeout_detection() {
_TIMEOUT_CMD=""
}
# Export functions for use in other scripts
export -f detect_timeout_command
export -f has_timeout_command
export -f get_timeout_status_message
export -f portable_timeout
export -f reset_timeout_detection

556
.ralph/lib/wizard_utils.sh Normal file
View File

@@ -0,0 +1,556 @@
#!/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" == [yY] ]]; then
yn_hint="[Y/n]"
fi
while true; do
# 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)
if [[ -z "$response" ]]; then
response="$default"
fi
case "$response" in
[yY]|[yY][eE][sS])
return 0
;;
[nN]|[nN][oO])
return 1
;;
*)
echo -e "${WIZARD_YELLOW}Please answer yes (y) or no (n)${WIZARD_NC}" >&2
;;
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
# Display prompt to stderr so command substitution only captures the response
if [[ -n "$default" ]]; then
echo -en "${WIZARD_CYAN}${prompt}${WIZARD_NC} [${default}]: " >&2
else
echo -en "${WIZARD_CYAN}${prompt}${WIZARD_NC}: " >&2
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
# Display prompt to stderr so command substitution only captures the response
if [[ -n "$default" ]]; then
echo -en "${WIZARD_CYAN}${prompt}${WIZARD_NC} [${default}]: " >&2
else
echo -en "${WIZARD_CYAN}${prompt}${WIZARD_NC}: " >&2
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}" >&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}" >&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}" >&2
continue
fi
if [[ -n "$max" && "$response" -gt "$max" ]]; then
echo -e "${WIZARD_YELLOW}Value must be at most ${max}${WIZARD_NC}" >&2
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
# 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}" >&2
((i++))
done
echo "" >&2
while true; do
echo -en "Select option [1-${num_options}]: " >&2
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}" >&2
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[@]}
# 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}" >&2
else
echo -e " ${WIZARD_CYAN}${i})${WIZARD_NC} ${opt}" >&2
fi
((i++))
done
echo "" >&2
while true; do
echo -en "Select option [1-${num_options}] (default: ${default_index}): " >&2
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}" >&2
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
# Guard against division by zero
if [[ $total -le 0 ]]; then
local bar_width=30
local bar=""
for ((i = 0; i < bar_width; i++)); do bar+="░"; done
echo -en "\r${WIZARD_CYAN}[${bar}]${WIZARD_NC} 0/${total} ${message}"
return 0
fi
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