refactor(ux): consolidate BMAD skills, update design system, and clean up Prisma generated client
This commit is contained in:
490
.ralph/lib/circuit_breaker.sh
Normal file
490
.ralph/lib/circuit_breaker.sh
Normal 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
123
.ralph/lib/date_utils.sh
Normal 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
820
.ralph/lib/enable_core.sh
Normal 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
|
||||
1562
.ralph/lib/response_analyzer.sh
Normal file
1562
.ralph/lib/response_analyzer.sh
Normal file
File diff suppressed because it is too large
Load Diff
611
.ralph/lib/task_sources.sh
Normal file
611
.ralph/lib/task_sources.sh
Normal 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
145
.ralph/lib/timeout_utils.sh
Normal 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
556
.ralph/lib/wizard_utils.sh
Normal 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
|
||||
Reference in New Issue
Block a user