Created
November 25, 2025 11:29
-
-
Save TKasperczyk/19d973b447cc49d8f483eb3419620fe3 to your computer and use it in GitHub Desktop.
Bash utility that recursively links documentation files in nested directories (e.g. AGENTS.md -> CLAUDE.md for AI assistants) with dry-run mode and safety checks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -uo pipefail | |
| # Script metadata | |
| SCRIPT_VERSION="1.0.1" | |
| SCRIPT_NAME="Agent Documentation Linker" | |
| # Default values | |
| DEFAULT_SOURCE="AGENTS.md" | |
| DEFAULT_TARGET="CLAUDE.md" | |
| DEFAULT_DEPTH="" | |
| DEFAULT_MODE="link" | |
| DEFAULT_DRY_RUN=false | |
| # Global exit status tracker | |
| EXIT_STATUS=0 | |
| # Detect if we're in a terminal and if it supports colors | |
| if [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && tput colors >/dev/null 2>&1; then | |
| COLORS=$(tput colors) | |
| if [[ -n "$COLORS" ]] && [[ "$COLORS" -ge 8 ]]; then | |
| USE_COLOR=true | |
| else | |
| USE_COLOR=false | |
| fi | |
| else | |
| USE_COLOR=false | |
| fi | |
| # Color codes and symbols | |
| if [[ "$USE_COLOR" == "true" ]]; then | |
| RED=$(tput setaf 1) | |
| GREEN=$(tput setaf 2) | |
| YELLOW=$(tput setaf 3) | |
| BLUE=$(tput setaf 4) | |
| CYAN=$(tput setaf 6) | |
| BOLD=$(tput bold) | |
| DIM=$(tput dim) | |
| RESET=$(tput sgr0) | |
| # Unicode symbols (with fallbacks) | |
| if [[ "${LANG:-}" =~ UTF-8 ]] || [[ "${LC_ALL:-}" =~ UTF-8 ]]; then | |
| SYMBOL_INFO="ℹ" | |
| SYMBOL_SUCCESS="✓" | |
| SYMBOL_WARNING="⚠" | |
| SYMBOL_ERROR="✗" | |
| SYMBOL_ARROW="→" | |
| else | |
| SYMBOL_INFO="i" | |
| SYMBOL_SUCCESS="+" | |
| SYMBOL_WARNING="!" | |
| SYMBOL_ERROR="x" | |
| SYMBOL_ARROW="->" | |
| fi | |
| else | |
| RED="" | |
| GREEN="" | |
| YELLOW="" | |
| BLUE="" | |
| CYAN="" | |
| BOLD="" | |
| DIM="" | |
| RESET="" | |
| SYMBOL_INFO="i" | |
| SYMBOL_SUCCESS="+" | |
| SYMBOL_WARNING="!" | |
| SYMBOL_ERROR="x" | |
| SYMBOL_ARROW="->" | |
| fi | |
| # Function to print colored output | |
| print_info() { | |
| echo "${BLUE}${SYMBOL_INFO}${RESET} $1" | |
| } | |
| print_success() { | |
| echo "${GREEN}${SYMBOL_SUCCESS}${RESET} $1" | |
| } | |
| print_warning() { | |
| echo "${YELLOW}${SYMBOL_WARNING}${RESET} $1" | |
| } | |
| print_error() { | |
| echo "${RED}${SYMBOL_ERROR}${RESET} $1" >&2 | |
| } | |
| print_header() { | |
| echo "${CYAN}${BOLD}$1${RESET}" | |
| } | |
| print_dim() { | |
| echo "${DIM}$1${RESET}" | |
| } | |
| # Function to print a separator line | |
| print_separator() { | |
| local char="${1:--}" | |
| local width="${2:-50}" | |
| printf '%*s\n' "$width" | tr ' ' "$char" | |
| } | |
| # Function to show usage | |
| usage() { | |
| echo "" | |
| print_header "$SCRIPT_NAME v$SCRIPT_VERSION" | |
| echo "" | |
| echo "${BOLD}USAGE${RESET}" | |
| echo " $(basename "$0") [OPTIONS]" | |
| echo "" | |
| echo "${BOLD}DESCRIPTION${RESET}" | |
| echo " Create or remove symlinks from source markdown files to target markdown files." | |
| echo " Recursively scans directories to find source files and creates symlinks alongside them." | |
| echo "" | |
| echo "${BOLD}OPTIONS${RESET}" | |
| printf " ${GREEN}-r${RESET}, ${GREEN}--root${RESET} PATH Root path to search ${DIM}(required)${RESET}\n" | |
| printf " ${GREEN}-s${RESET}, ${GREEN}--source${RESET} NAME Source filename ${DIM}(default: $DEFAULT_SOURCE)${RESET}\n" | |
| printf " ${GREEN}-t${RESET}, ${GREEN}--target${RESET} NAME Target filename ${DIM}(default: $DEFAULT_TARGET)${RESET}\n" | |
| printf " ${GREEN}-d${RESET}, ${GREEN}--depth${RESET} NUM Maximum search depth ${DIM}(default: unlimited)${RESET}\n" | |
| printf " ${GREEN}-m${RESET}, ${GREEN}--mode${RESET} MODE Operation mode: ${CYAN}link${RESET} or ${CYAN}unlink${RESET} ${DIM}(default: $DEFAULT_MODE)${RESET}\n" | |
| printf " ${GREEN}-n${RESET}, ${GREEN}--dry-run${RESET} Show what would be done without making changes\n" | |
| printf " ${GREEN}-h${RESET}, ${GREEN}--help${RESET} Show this help message\n" | |
| echo "" | |
| echo "${BOLD}EXAMPLES${RESET}" | |
| print_dim " # Interactive mode" | |
| echo " $(basename "$0")" | |
| echo "" | |
| print_dim " # Link AGENTS.md to CLAUDE.md in current directory" | |
| echo " $(basename "$0") -r ." | |
| echo "" | |
| print_dim " # Link with custom depth" | |
| echo " $(basename "$0") -r /path/to/project -d 3" | |
| echo "" | |
| print_dim " # Dry run - see what would be done" | |
| echo " $(basename "$0") -r . --dry-run" | |
| echo "" | |
| print_dim " # Unlink all CLAUDE.md symlinks" | |
| echo " $(basename "$0") -r . -m unlink" | |
| echo "" | |
| print_dim " # Link with custom filenames" | |
| echo " $(basename "$0") -r . -s DOCS.md -t README.md" | |
| echo "" | |
| exit 0 | |
| } | |
| # Function to prompt for input with default value | |
| prompt_input() { | |
| local prompt="$1" | |
| local default="$2" | |
| local result | |
| # Write prompt to stderr so it's not captured by command substitution | |
| if [[ -n "$default" ]]; then | |
| printf "${BLUE}?${RESET} ${BOLD}%s${RESET} ${DIM}[%s]${RESET}: " "$prompt" "$default" >&2 | |
| else | |
| printf "${BLUE}?${RESET} ${BOLD}%s${RESET}: " "$prompt" >&2 | |
| fi | |
| # Read from stdin | |
| read -r result | |
| # Return result or default | |
| echo "${result:-$default}" | |
| } | |
| # Function to display a banner | |
| print_banner() { | |
| echo "" | |
| print_header "$SCRIPT_NAME" | |
| print_dim "v$SCRIPT_VERSION" | |
| echo "" | |
| } | |
| # Function to validate if file is a symlink | |
| is_symlink() { | |
| local file="$1" | |
| [[ -L "$file" ]] | |
| } | |
| # Function to find and link files | |
| link_files() { | |
| local root="$1" | |
| local source="$2" | |
| local target="$3" | |
| local depth="$4" | |
| local dry_run="$5" | |
| local find_args=("$root" -type f -name "$source") | |
| if [[ -n "$depth" ]]; then | |
| find_args=("$root" -maxdepth "$depth" -type f -name "$source") | |
| fi | |
| print_info "Searching for '$source' files in '$root'..." | |
| local count=0 | |
| local created=0 | |
| local skipped=0 | |
| local errors=0 | |
| local dir | |
| local target_file | |
| local link_target | |
| while IFS= read -r source_file || [[ -n "$source_file" ]]; do | |
| [[ -z "$source_file" ]] && continue | |
| ((count++)) | |
| dir="$(dirname "$source_file")" | |
| target_file="$dir/$target" | |
| if [[ -e "$target_file" ]] || [[ -L "$target_file" ]]; then | |
| if is_symlink "$target_file"; then | |
| link_target="$(readlink "$target_file")" | |
| if [[ "$link_target" == "$source" ]]; then | |
| print_warning "Symlink already exists: ${DIM}$target_file${RESET} ${SYMBOL_ARROW} ${CYAN}$source${RESET}" | |
| ((skipped++)) | |
| else | |
| print_warning "Symlink exists but points elsewhere: ${DIM}$target_file${RESET} ${SYMBOL_ARROW} ${CYAN}$link_target${RESET}" | |
| ((skipped++)) | |
| fi | |
| else | |
| print_error "File exists and is not a symlink: ${DIM}$target_file${RESET}" | |
| ((errors++)) | |
| fi | |
| else | |
| if [[ "$dry_run" == "true" ]]; then | |
| print_info "${DIM}[DRY RUN]${RESET} Would create: ${DIM}$target_file${RESET} ${SYMBOL_ARROW} ${CYAN}$source${RESET}" | |
| ((created++)) | |
| else | |
| if ln -s "$source" "$target_file" 2>/dev/null; then | |
| print_success "Created symlink: ${DIM}$target_file${RESET} ${SYMBOL_ARROW} ${CYAN}$source${RESET}" | |
| ((created++)) | |
| else | |
| print_error "Failed to create symlink: ${DIM}$target_file${RESET}" | |
| ((errors++)) | |
| fi | |
| fi | |
| fi | |
| done < <(find "${find_args[@]}" 2>&1 | grep -v "Permission denied" || true) | |
| echo "" | |
| print_separator "-" 50 | |
| print_header "Summary" | |
| echo "" | |
| printf " ${BOLD}Found:${RESET} ${CYAN}%d${RESET} %s file(s)\n" "$count" "$source" | |
| if [[ "$dry_run" == "true" ]]; then | |
| printf " ${BOLD}Would create:${RESET} ${GREEN}%d${RESET} symlink(s)\n" "$created" | |
| else | |
| printf " ${BOLD}Created:${RESET} ${GREEN}%d${RESET} symlink(s)\n" "$created" | |
| fi | |
| if [[ "$skipped" -gt 0 ]]; then | |
| printf " ${BOLD}Skipped:${RESET} ${YELLOW}%d${RESET} item(s)\n" "$skipped" | |
| fi | |
| if [[ "$errors" -gt 0 ]]; then | |
| printf " ${BOLD}Errors:${RESET} ${RED}%d${RESET} failure(s)\n" "$errors" | |
| EXIT_STATUS=1 | |
| fi | |
| echo "" | |
| } | |
| # Function to find and unlink files | |
| unlink_files() { | |
| local root="$1" | |
| local source="$2" | |
| local target="$3" | |
| local depth="$4" | |
| local dry_run="$5" | |
| local find_args=("$root" -type f -name "$source") | |
| if [[ -n "$depth" ]]; then | |
| find_args=("$root" -maxdepth "$depth" -type f -name "$source") | |
| fi | |
| print_info "Searching for '$source' files in '$root'..." | |
| local count=0 | |
| local removed=0 | |
| local skipped=0 | |
| local errors=0 | |
| local dir | |
| local target_file | |
| local link_target | |
| while IFS= read -r source_file || [[ -n "$source_file" ]]; do | |
| [[ -z "$source_file" ]] && continue | |
| ((count++)) | |
| dir="$(dirname "$source_file")" | |
| target_file="$dir/$target" | |
| if [[ -e "$target_file" ]] || [[ -L "$target_file" ]]; then | |
| if is_symlink "$target_file"; then | |
| link_target="$(readlink "$target_file")" | |
| if [[ "$link_target" == "$source" ]]; then | |
| if [[ "$dry_run" == "true" ]]; then | |
| print_info "${DIM}[DRY RUN]${RESET} Would remove: ${DIM}$target_file${RESET}" | |
| ((removed++)) | |
| else | |
| if rm "$target_file" 2>/dev/null; then | |
| print_success "Removed symlink: ${DIM}$target_file${RESET}" | |
| ((removed++)) | |
| else | |
| print_error "Failed to remove: ${DIM}$target_file${RESET}" | |
| ((errors++)) | |
| fi | |
| fi | |
| else | |
| print_warning "Symlink points elsewhere (skipping): ${DIM}$target_file${RESET} ${SYMBOL_ARROW} ${CYAN}$link_target${RESET}" | |
| ((skipped++)) | |
| fi | |
| else | |
| print_error "Not a symlink (safety check): ${DIM}$target_file${RESET}" | |
| ((errors++)) | |
| fi | |
| else | |
| print_warning "Target doesn't exist: ${DIM}$target_file${RESET}" | |
| ((skipped++)) | |
| fi | |
| done < <(find "${find_args[@]}" 2>&1 | grep -v "Permission denied" || true) | |
| echo "" | |
| print_separator "-" 50 | |
| print_header "Summary" | |
| echo "" | |
| printf " ${BOLD}Found:${RESET} ${CYAN}%d${RESET} %s file(s)\n" "$count" "$source" | |
| if [[ "$dry_run" == "true" ]]; then | |
| printf " ${BOLD}Would remove:${RESET} ${GREEN}%d${RESET} symlink(s)\n" "$removed" | |
| else | |
| printf " ${BOLD}Removed:${RESET} ${GREEN}%d${RESET} symlink(s)\n" "$removed" | |
| fi | |
| if [[ "$skipped" -gt 0 ]]; then | |
| printf " ${BOLD}Skipped:${RESET} ${YELLOW}%d${RESET} item(s)\n" "$skipped" | |
| fi | |
| if [[ "$errors" -gt 0 ]]; then | |
| printf " ${BOLD}Errors:${RESET} ${RED}%d${RESET} failure(s)\n" "$errors" | |
| EXIT_STATUS=1 | |
| fi | |
| echo "" | |
| } | |
| # Parse command line arguments | |
| ROOT="" | |
| SOURCE="$DEFAULT_SOURCE" | |
| TARGET="$DEFAULT_TARGET" | |
| DEPTH="$DEFAULT_DEPTH" | |
| MODE="$DEFAULT_MODE" | |
| DRY_RUN="$DEFAULT_DRY_RUN" | |
| while [[ $# -gt 0 ]]; do | |
| case $1 in | |
| -r|--root) | |
| ROOT="$2" | |
| shift 2 | |
| ;; | |
| -s|--source) | |
| SOURCE="$2" | |
| shift 2 | |
| ;; | |
| -t|--target) | |
| TARGET="$2" | |
| shift 2 | |
| ;; | |
| -d|--depth) | |
| DEPTH="$2" | |
| shift 2 | |
| ;; | |
| -m|--mode) | |
| MODE="$2" | |
| shift 2 | |
| ;; | |
| -n|--dry-run) | |
| DRY_RUN=true | |
| shift | |
| ;; | |
| -h|--help) | |
| usage | |
| ;; | |
| *) | |
| print_error "Unknown option: $1" | |
| usage | |
| ;; | |
| esac | |
| done | |
| # Interactive mode if no root specified | |
| if [[ -z "$ROOT" ]]; then | |
| print_banner | |
| print_header "Configuration" | |
| echo "" | |
| ROOT=$(prompt_input "Root path to search" ".") | |
| SOURCE=$(prompt_input "Source filename" "$DEFAULT_SOURCE") | |
| TARGET=$(prompt_input "Target filename" "$DEFAULT_TARGET") | |
| DEPTH=$(prompt_input "Maximum search depth (leave empty for unlimited)" "") | |
| echo "" | |
| print_header "Operation Mode" | |
| echo "" | |
| printf " ${CYAN}1)${RESET} Link ${DIM}(create symlinks)${RESET}\n" | |
| printf " ${CYAN}2)${RESET} Unlink ${DIM}(remove symlinks)${RESET}\n" | |
| echo "" | |
| mode_choice=$(prompt_input "Select mode" "1") | |
| case "$mode_choice" in | |
| 1) | |
| MODE="link" | |
| ;; | |
| 2) | |
| MODE="unlink" | |
| ;; | |
| *) | |
| print_error "Invalid choice" | |
| exit 1 | |
| ;; | |
| esac | |
| echo "" | |
| dry_run_choice=$(prompt_input "Dry run (preview changes without applying)" "n") | |
| if [[ "$dry_run_choice" =~ ^[Yy] ]]; then | |
| DRY_RUN=true | |
| fi | |
| echo "" | |
| fi | |
| # Validate inputs | |
| if [[ -z "$ROOT" ]]; then | |
| print_error "Root path is required" | |
| usage | |
| fi | |
| if [[ ! -d "$ROOT" ]]; then | |
| print_error "Root path does not exist or is not a directory: $ROOT" | |
| exit 1 | |
| fi | |
| if [[ "$MODE" != "link" && "$MODE" != "unlink" ]]; then | |
| print_error "Invalid mode: $MODE (must be 'link' or 'unlink')" | |
| exit 1 | |
| fi | |
| if [[ -n "$DEPTH" && ! "$DEPTH" =~ ^[0-9]+$ ]]; then | |
| print_error "Depth must be a positive integer" | |
| exit 1 | |
| fi | |
| # Show configuration | |
| echo "" | |
| print_separator "=" 50 | |
| print_header "Configuration" | |
| echo "" | |
| printf " ${BOLD}Root path:${RESET} ${CYAN}%s${RESET}\n" "$ROOT" | |
| printf " ${BOLD}Source file:${RESET} ${CYAN}%s${RESET}\n" "$SOURCE" | |
| printf " ${BOLD}Target file:${RESET} ${CYAN}%s${RESET}\n" "$TARGET" | |
| printf " ${BOLD}Max depth:${RESET} ${CYAN}%s${RESET}\n" "${DEPTH:-unlimited}" | |
| printf " ${BOLD}Mode:${RESET} ${CYAN}%s${RESET}\n" "$MODE" | |
| if [[ "$DRY_RUN" == "true" ]]; then | |
| printf " ${BOLD}Dry run:${RESET} ${YELLOW}enabled${RESET}\n" | |
| else | |
| printf " ${BOLD}Dry run:${RESET} ${DIM}disabled${RESET}\n" | |
| fi | |
| echo "" | |
| print_separator "=" 50 | |
| echo "" | |
| # Execute based on mode | |
| case "$MODE" in | |
| link) | |
| link_files "$ROOT" "$SOURCE" "$TARGET" "$DEPTH" "$DRY_RUN" | |
| ;; | |
| unlink) | |
| unlink_files "$ROOT" "$SOURCE" "$TARGET" "$DEPTH" "$DRY_RUN" | |
| ;; | |
| esac | |
| print_separator "=" 50 | |
| if [[ "$EXIT_STATUS" -eq 0 ]]; then | |
| print_success "${BOLD}Operation completed successfully!${RESET}" | |
| else | |
| print_error "${BOLD}Operation completed with errors${RESET}" | |
| fi | |
| print_separator "=" 50 | |
| echo "" | |
| exit "$EXIT_STATUS" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment