#!/bin/bash # AI-Unix Common Library # Beautiful, composable functions following McConnell's principles # Exit codes (standardized across all tools) readonly EXIT_SUCCESS=0 readonly EXIT_NO_MATCH=1 readonly EXIT_USAGE_ERROR=2 readonly EXIT_LLM_ERROR=3 readonly EXIT_API_ERROR=4 readonly EXIT_COMMAND_NOT_FOUND=127 readonly EXIT_INTERRUPTED=130 # Signal handling setup_signal_handling() { trap 'exit $EXIT_INTERRUPTED' SIGINT SIGTERM } # Pure validation functions (no side effects, single responsibility) # ================================================================ validate_non_empty_string() { local value="$1" [[ -n "$value" && -n "$(echo "$value" | tr -d '[:space:]')" ]] } validate_file_exists() { local file="$1" [[ -n "$file" && -f "$file" && -r "$file" ]] } validate_command_available() { local command="$1" command -v "$command" >/dev/null 2>&1 } validate_json_format() { local text="$1" echo "$text" | grep -q "^{.*}$" } validate_boolean_response() { local text="$1" echo "$text" | grep -iq "^true$\|^false$" } # Pure data processing functions (deterministic, no side effects) # ============================================================== read_from_stdin() { cat } read_from_file() { local file="$1" validate_file_exists "$file" && cat "$file" } read_from_files() { local files=("$@") local file for file in "${files[@]}"; do validate_file_exists "$file" || return 1 done cat "${files[@]}" } normalize_whitespace() { local text="$1" echo "$text" | tr -d '[:space:]' } extract_number() { local text="$1" echo "$text" | grep -o '[0-9]\+' | head -1 } extract_boolean() { local text="$1" echo "$text" | tr '[:lower:]' '[:upper:]' | grep -o 'TRUE\|FALSE' | head -1 } filter_explanatory_text() { local text="$1" echo "$text" | grep -v "^Looking at\|^I'll\|^Here are\|^The following\|^Based on\|^Analyzing\|^To find\|^These are\|^Let me\|^I need to" | sed '/^[[:space:]]*$/d' } # Error message generators (pure functions) # ========================================= error_file_not_found() { local file="$1" echo "Error: File '$file' not found or not readable" } error_missing_argument() { local arg_name="$1" echo "Error: $arg_name is required" } error_invalid_option() { local option="$1" echo "Invalid option: -$option" } error_command_not_found() { local command="$1" printf "Error: '%s' command not found. Please install Claude CLI.\nVisit: https://claude.ai/cli for installation instructions." "$command" } error_llm_no_response() { echo "Error: No response received from LLM" } error_llm_api_error() { echo "Error: Claude API error. Check your connection and API key." } error_llm_command_failed() { echo "Error: Claude command failed" } error_invalid_json() { echo "Error: Invalid JSON format in LLM response" } error_invalid_boolean() { echo "Error: Invalid boolean response from LLM - expected TRUE or FALSE" } error_invalid_count() { echo "Error: Invalid count format in LLM response" } # I/O functions (controlled side effects) # ======================================= print_error() { local message="$1" echo "$message" >&2 } print_usage_and_exit() { local usage_func="$1" local exit_code="${2:-$EXIT_USAGE_ERROR}" "$usage_func" >&2 exit "$exit_code" } # Input processing (returns data via stdout, status via return code) process_input_sources() { local files=("$@") local input # Determine input source and read data if [[ ${#files[@]} -eq 0 ]]; then input=$(read_from_stdin) elif [[ ${#files[@]} -eq 1 ]]; then input=$(read_from_file "${files[0]}") else input=$(read_from_files "${files[@]}") fi # Early return on read failure [[ $? -ne 0 ]] && return $EXIT_USAGE_ERROR # Early return on empty input validate_non_empty_string "$input" || return $EXIT_NO_MATCH echo "$input" return 0 } # LLM interaction (prompt and context as separate arguments) execute_llm_request() { local prompt="$1" local context="$2" if ! validate_non_empty_string "$prompt"; then return $EXIT_USAGE_ERROR fi local result exit_code temp_dir # Create temporary directory and execute Claude in it to isolate from project context temp_dir=$(mktemp -d) || return $EXIT_USAGE_ERROR result=$( cd "$temp_dir" || exit 1 if [ -n "$context" ]; then echo "$context" | claude -p "$prompt" --disallowedTools "*" 2>/dev/null else claude -p "$prompt" --disallowedTools "*" --max-turns 1 2>/dev/null fi ) exit_code=$? # Clean up temporary directory (only if it was successfully created) [[ -n "$temp_dir" && -d "$temp_dir" ]] && rmdir "$temp_dir" case $exit_code in 0) if validate_non_empty_string "$result"; then echo "$result" return 0 else return $EXIT_NO_MATCH fi ;; 2) return $EXIT_API_ERROR ;; *) return $EXIT_LLM_ERROR ;; esac } # Standardized error handling for LLM operations # =============================================== handle_llm_error() { local error_code="$1" case "$error_code" in "$EXIT_NO_MATCH") print_error "$(error_llm_no_response)" ;; "$EXIT_API_ERROR") print_error "$(error_llm_api_error)" ;; *) print_error "$(error_llm_command_failed)" ;; esac exit "$error_code" } # High-level validation with error reporting # ========================================== ensure_dependencies() { if ! validate_command_available "claude"; then print_error "$(error_command_not_found "claude")" exit $EXIT_COMMAND_NOT_FOUND fi } ensure_files_exist() { local files=("$@") local file for file in "${files[@]}"; do if ! validate_file_exists "$file"; then print_error "$(error_file_not_found "$file")" exit $EXIT_USAGE_ERROR fi done } ensure_argument_provided() { local arg_name="$1" local arg_value="$2" local usage_func="$3" if ! validate_non_empty_string "$arg_value"; then print_error "$(error_missing_argument "$arg_name")" [[ -n "$usage_func" ]] && print_usage_and_exit "$usage_func" exit $EXIT_USAGE_ERROR fi } # Standard option handlers # ======================= handle_invalid_option() { local option="$1" print_error "$(error_invalid_option "$option")" exit $EXIT_USAGE_ERROR } handle_help_option() { local usage_func="$1" "$usage_func" exit $EXIT_SUCCESS } # LLM response processors (specialized for each utility type) # ========================================================== process_categorization_response() { local response="$1" if echo "$response" | grep -q ":"; then echo "$response" return 0 else print_error "Error: Invalid response format from LLM - expected 'category: content'" return $EXIT_LLM_ERROR fi } process_json_response() { local response="$1" if validate_json_format "$response"; then echo "$response" return 0 else print_error "$(error_invalid_json)" return $EXIT_LLM_ERROR fi } process_boolean_response() { local response="$1" local normalized normalized=$(extract_boolean "$response") if [[ -z "$normalized" ]]; then print_error "$(error_invalid_boolean)" return $EXIT_LLM_ERROR fi echo "$normalized" return 0 } process_count_response() { local response="$1" local count count=$(extract_number "$response") if [[ -n "$count" && "$count" -ge 0 ]]; then echo "$count" return 0 else print_error "$(error_invalid_count)" return $EXIT_LLM_ERROR fi } process_filtered_response() { local response="$1" local filtered filtered=$(filter_explanatory_text "$response") if validate_non_empty_string "$filtered"; then echo "$filtered" return 0 else return $EXIT_NO_MATCH fi } # Initialization setup_signal_handling