├── README.md └── llmcat /README.md: -------------------------------------------------------------------------------- 1 | # llmcat 2 | 3 | 4 | 5 | 6 | Fast and flexible tool for copying files to large language models from command-line, supporting fuzzy search, multi-file selection and automatically respecting `.gitignore` rules. 7 | 8 | ```bash 9 | # Copy specific file 10 | $ llmcat path/to/file.txt 11 | 12 | # Copy directory 13 | $ llmcat ./src/ 14 | 15 | # Interactive mode (opens fuzzy finder) 16 | $ llmcat 17 | ``` 18 | 19 | Output format: 20 | 21 | ```markdown 22 | # Directory: src/state 23 | 24 | [file tree] 25 | 26 | ## File: src/state/config.ts 27 | --- 28 | [file contents] 29 | ``` 30 | 31 | See also: [Interactive Demo]() | [Blog](https://azerkoculu.com/posts/llmcat-copy-code-from-cli-to-llms) 32 | 33 | ## Install 34 | 35 | ```bash 36 | # Download the script 37 | curl -o llmcat https://raw.githubusercontent.com/azer/llmcat/main/llmcat 38 | 39 | # Make it executable 40 | chmod +x llmcat 41 | 42 | # Move to your PATH 43 | sudo mv llmcat /usr/local/bin/ 44 | ``` 45 | 46 | Required dependencies: 47 | * fd (for file discovery) 48 | * fzf (for interactive selection) 49 | * bat (for file preview in interactive mode) (optional) 50 | 51 | ## Usage 52 | 53 | #### Interactive 54 | 55 | When no path provided, llmcat opens fzf where you can search and select files by just pressing `tab` key. 56 | 57 | Keybindings: 58 | * tab: Select file (moves up after selection) 59 | * shift-tab: Unselect file 60 | * ctrl-/: Toggle preview 61 | * ctrl-d: Directory mode 62 | * ctrl-f: File mode 63 | * enter: Confirm 64 | * esc: Exit 65 | 66 | **Example:** 67 | 68 | Search and select files directories in a Phoenix project: 69 | 70 | ![llmcat 3](https://github.com/user-attachments/assets/d53ee548-8900-4b1a-bbc7-69a0c01b72e8) 71 | 72 | #### Command-line 73 | 74 | ```bash 75 | # Copy a single file 76 | $ llmcat src/main.rs 77 | 78 | # Copy directory 79 | $ llmcat src/ 80 | 81 | # Ignore specific files (uses fd glob patterns) 82 | $ llmcat -i "*.log" ./src/ 83 | 84 | # Ignore multiple patterns 85 | $ llmcat -i "*.log" -i "*.tmp" ./src/ 86 | 87 | # Don't respect gitignore files 88 | $ llmcat -n ./src/ 89 | 90 | # Include hidden files/directories 91 | $ llmcat -H ./src/ 92 | 93 | # Print output while copying 94 | $ llmcat -p file.txt 95 | 96 | # Print only the directory tree 97 | $ llmcat -t ./src/ 98 | ``` 99 | 100 | # Manual 101 | 102 | ``` 103 | llmcat - Prepare files and directories for LLM consumption 104 | 105 | Usage: llmcat [options] [path] 106 | llmcat (interactive mode with fzf) 107 | 108 | Options: 109 | -h, --help Show this help message 110 | -i, --ignore PATTERN Additional ignore patterns (fd exclude format: glob pattern) 111 | -v, --version Show version 112 | -t, --tree-only Only output directory tree 113 | -q, --quiet Silent mode (only copy to clipboard) 114 | -p, --print Print copied files/content (default: quiet) 115 | -n, --no-ignore Don't respect gitignore/ignore files 116 | -H, --hidden Include hidden files/directories 117 | --debug Enable debug output 118 | 119 | Interactive Mode (fzf): 120 | tab - Select/mark multiple files 121 | shift-tab - Unselect/unmark file 122 | ctrl-/ - Toggle preview window 123 | ctrl-d - Select directory mode 124 | ctrl-f - Select file mode 125 | enter - Confirm selection(s) 126 | esc - Exit 127 | 128 | Examples: 129 | # Interactive file selection 130 | llmcat 131 | 132 | # Process specific file 133 | llmcat path/to/file.txt 134 | 135 | # Process directory with custom ignore 136 | llmcat -i "*.log" -i "*.tmp" ./src/ 137 | 138 | # Print content while copying 139 | llmcat -p ./src/file.txt 140 | 141 | Features: 142 | - Interactive fuzzy finder with file preview 143 | - Auto-copies output to clipboard 144 | - Automatically respects .gitignore, .ignore, and .fdignore files 145 | - Directory tree visualization 146 | - Multi-file selection 147 | - Cross-platform (Linux/OSX) 148 | ``` 149 | 150 | ## Behavior Changes from v1.x 151 | 152 | - Uses `fd` instead of `find`, automatically respecting `.gitignore` files 153 | - Hidden files/directories are excluded by default (unlike `find`) 154 | - Match patterns now use fd's glob syntax instead of grep patterns 155 | -------------------------------------------------------------------------------- /llmcat: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Config 4 | set -eo pipefail 5 | CLIP_CMD="" 6 | VERSION="2.0.0" 7 | QUIET="true" 8 | NOCLIP="false" 9 | DEBUG="false" 10 | FILE_COUNT=0 # Initialize the file counter 11 | 12 | # Help text 13 | show_help() { 14 | cat << EOF 15 | llmcat - Prepare files and directories for LLM consumption 16 | 17 | Usage: llmcat [options] [path] 18 | llmcat (interactive mode with fzf) 19 | 20 | Options: 21 | -h, --help Show this help message 22 | -i, --ignore PATTERN Additional ignore patterns (fd exclude format: glob pattern) 23 | -v, --version Show version 24 | -t, --tree-only Only output directory tree 25 | -q, --quiet Silent mode (only copy to clipboard) 26 | -p, --print Print copied files/content, and also copy to clipboard (default: quiet) 27 | -P, --print-only Print copied files/content, but DON'T copy to clipboard (default: quiet) 28 | -n, --no-ignore Don't respect gitignore/ignore files 29 | -H, --hidden Include hidden files/directories 30 | --debug Enable debug output 31 | 32 | Interactive Mode (fzf): 33 | tab - Select/mark multiple files 34 | shift-tab - Unselect/unmark file 35 | ctrl-/ - Toggle preview window 36 | ctrl-d - Select directory mode 37 | ctrl-f - Select file mode 38 | enter - Confirm selection(s) 39 | esc - Exit 40 | 41 | Examples: 42 | # Interactive file selection 43 | llmcat 44 | 45 | # Process specific file 46 | llmcat path/to/file.txt 47 | 48 | # Process directory with custom ignore 49 | llmcat -i "*.log" -i "*.tmp" ./src/ 50 | 51 | # Print content while copying 52 | llmcat -p ./src/file.txt 53 | 54 | # Print content without copying to clipboard 55 | llmcat -P ./src/file.txt 56 | 57 | Features: 58 | - Interactive fuzzy finder with file preview 59 | - Auto-copies output to clipboard 60 | - Automatically respects .gitignore, .ignore, and .fdignore files 61 | - Directory tree visualization 62 | - Multi-file selection 63 | - Cross-platform (Linux/OSX) 64 | 65 | Behavior Changes from v1.x: 66 | - Uses fd instead of find, automatically respecting .gitignore files 67 | - Hidden files/directories are excluded by default (unlike find) 68 | - Match patterns now use fd's syntax instead of grep patterns 69 | 70 | Author: 71 | Azer Koculu (https://azerkoculu.com) 72 | 73 | See Also: 74 | Project Home: 75 | https://github.com/azer/llmcat 76 | EOF 77 | } 78 | 79 | # Debug helper 80 | debug() { 81 | if [ "$DEBUG" = "true" ]; then 82 | printf "DEBUG: %s\n" "$*" >&2 83 | fi 84 | } 85 | 86 | # Detect OS and set clipboard command 87 | detect_os() { 88 | case "$(uname)" in 89 | "Darwin") 90 | CLIP_CMD="pbcopy" 91 | command -v pbcopy >/dev/null 2>&1 || { 92 | echo "Error: pbcopy not found" >&2 93 | exit 1 94 | } 95 | ;; 96 | "Linux") 97 | if command -v wl-copy >/dev/null 2>&1; then 98 | CLIP_CMD="wl-copy" 99 | elif command -v xclip >/dev/null 2>&1; then 100 | CLIP_CMD="xclip -selection clipboard" 101 | elif command -v xsel >/dev/null 2>&1; then 102 | CLIP_CMD="xsel --clipboard --input" 103 | else 104 | echo "Error: Install xclip or xsel for clipboard support" >&2 105 | exit 1 106 | fi 107 | ;; 108 | *) 109 | echo "Error: Unsupported OS" >&2 110 | exit 1 111 | ;; 112 | esac 113 | } 114 | 115 | # Find git root or current directory 116 | find_root() { 117 | if git rev-parse --git-dir >/dev/null 2>&1; then 118 | git rev-parse --show-toplevel 119 | else 120 | pwd 121 | fi 122 | } 123 | 124 | # Check for dependencies 125 | check_dependencies() { 126 | local missing=false 127 | 128 | if ! command -v fd >/dev/null 2>&1; then 129 | echo "Interactive mode requires fd. Install with:" 130 | echo " brew install fd # macOS" 131 | echo " apt install fd-find # Ubuntu (available as 'fdfind')" 132 | echo " https://github.com/sharkdp/fd#installation" 133 | missing=true 134 | fi 135 | 136 | if ! command -v fzf >/dev/null 2>&1; then 137 | echo "Interactive mode requires fzf. Install with:" 138 | echo " brew install fzf # macOS" 139 | echo " apt install fzf # Ubuntu" 140 | echo 141 | missing=true 142 | fi 143 | 144 | [ "$missing" = "true" ] && return 1 || return 0 145 | } 146 | 147 | # Run fzf with configuration 148 | run_fzf() { 149 | local fd_opts="$1" 150 | local root_dir 151 | root_dir=$(find_root) 152 | 153 | debug "Running fzf from: $root_dir" 154 | 155 | # Preview script to handle files vs directories 156 | local preview_cmd=' 157 | if [ -f {} ]; then 158 | bat --style=numbers --color=always {} 2>/dev/null || cat {} 159 | elif [ -d {} ]; then 160 | echo "\n Directory: {}\n" 161 | tree -C {} 2>/dev/null || ls -la {} 2>/dev/null 162 | fi' 163 | 164 | # Change to root directory temporarily 165 | (cd "$root_dir" && { 166 | local fd_cmd="fd . --color=never $fd_opts" 167 | 168 | debug "Fd command: $fd_cmd" 169 | 170 | # Execute fd command and pipe to fzf, adding the current directory 171 | # to maintain original behavior 172 | (echo "."; eval "$fd_cmd") | fzf \ 173 | --preview "$preview_cmd" \ 174 | --preview-window 'right:60%:border-left' \ 175 | --bind 'ctrl-/:toggle-preview' \ 176 | --bind "ctrl-d:change-prompt(Select directories > )+reload(echo \".\"; fd . --type directory --color=never $fd_opts)" \ 177 | --bind "ctrl-f:change-prompt(Select files > )+reload(fd . --type file --color=never $fd_opts)" \ 178 | --bind 'tab:toggle+up' \ 179 | --height '80%' \ 180 | --border=rounded \ 181 | --prompt '⚡ Select files/dirs > ' \ 182 | --multi \ 183 | --color 'fg+:252,bg+:-1,hl:148,hl+:154,pointer:032,marker:010,prompt:064,border:240,separator:240' 184 | }) 185 | } 186 | 187 | # Get relative path from root 188 | get_relative_path() { 189 | local path="$1" 190 | local root_dir 191 | root_dir=$(find_root) 192 | echo "${path#$root_dir/}" 193 | } 194 | 195 | # Process file content 196 | process_file() { 197 | local file="$1" 198 | local rel_path 199 | rel_path=$(get_relative_path "$file") 200 | # Note: Don't increment counter here since this runs in a subshell 201 | { 202 | echo "## File: $rel_path" 203 | echo "---" 204 | cat "$file" 205 | echo 206 | } 207 | } 208 | 209 | # Process directory content 210 | process_dir() { 211 | local dir="$1" 212 | local fd_opts="$2" 213 | local tree_only="$3" 214 | local rel_path 215 | rel_path=$(get_relative_path "$dir") 216 | 217 | { 218 | echo "# Directory: $rel_path" 219 | echo "---" 220 | echo 221 | 222 | # Tree output 223 | local tree_output 224 | if command -v tree >/dev/null 2>&1; then 225 | # Use tree command if available with ignore patterns 226 | tree_output=$(cd "$dir" && tree) 227 | else 228 | # Use fd to simulate a tree view 229 | local fd_tree_cmd="cd \"$dir\" && fd . --color=never $fd_opts" 230 | 231 | # Create tree-like output with sed 232 | tree_output=$(eval "$fd_tree_cmd" | sed -e "s/[^-][^\/]*\// |--/g" -e "s/|\([^ ]\)/|-\1/") 233 | fi 234 | echo "$tree_output" 235 | 236 | # Process files only if not tree_only 237 | if [ "$tree_only" != "true" ]; then 238 | # Find all files with fd 239 | local fd_files_cmd="cd \"$dir\" && fd . --type file --color=never $fd_opts" 240 | 241 | # Process each file 242 | eval "$fd_files_cmd" | while IFS= read -r file; do 243 | echo 244 | process_file "$dir/$file" 245 | done 246 | fi 247 | } 248 | } 249 | 250 | # Handle output 251 | output_handler() { 252 | local content="$1" 253 | 254 | # Copy to clipboard unless -P option was used 255 | if [ "$NOCLIP" = "false" ]; then 256 | echo -n "$content" | eval "$CLIP_CMD" 257 | fi 258 | 259 | # Print if not quiet or if it's tree-only mode 260 | if [ "$QUIET" = "false" ] || [ "$tree_only" = "true" ]; then 261 | echo "$content" 262 | fi 263 | 264 | # Show feedback only for file copies, not tree-only mode 265 | if [ "$tree_only" != "true" ]; then 266 | if [ "$NOCLIP" = "false" ]; then 267 | echo "Copied $FILE_COUNT file(s) to clipboard" >&2 268 | else 269 | echo "Copied $FILE_COUNT file(s) to STDOUT" >&2 270 | fi 271 | fi 272 | } 273 | 274 | # Process multiple targets 275 | process_targets() { 276 | local output="" 277 | local target 278 | local fd_opts="$1" 279 | shift 280 | 281 | # Reset file counter before processing 282 | FILE_COUNT=0 283 | 284 | for target in "$@"; do 285 | debug "Processing: $target" 286 | if [ -f "$target" ]; then 287 | FILE_COUNT=$((FILE_COUNT + 1)) 288 | output+="$(process_file "$target")" 289 | elif [ -d "$target" ]; then 290 | # For directories, we need to count the files inside 291 | if [ "$tree_only" != "true" ]; then 292 | # Count files in directory 293 | local file_count_in_dir 294 | file_count_in_dir=$(cd "$target" && fd . --type file --color=never $fd_opts | wc -l) 295 | FILE_COUNT=$((FILE_COUNT + file_count_in_dir)) 296 | fi 297 | output+="$(process_dir "$target" "$fd_opts" "$tree_only")" 298 | else 299 | echo "Warning: Target not found - $target" >&2 300 | continue 301 | fi 302 | output+=$'\n\n' 303 | done 304 | 305 | output_handler "$output" 306 | } 307 | 308 | main() { 309 | local fd_opts="" 310 | local tree_only="false" 311 | local targets=() 312 | 313 | # Parse arguments 314 | while [[ $# -gt 0 ]]; do 315 | case $1 in 316 | -h|--help) show_help; exit 0 ;; 317 | -v|--version) echo "llmcat version $VERSION"; exit 0 ;; 318 | -i|--ignore) 319 | fd_opts+=" --exclude \"$2\""; 320 | shift 2 ;; 321 | -n|--no-ignore) 322 | fd_opts+=" --no-ignore"; 323 | shift ;; 324 | -H|--hidden) 325 | fd_opts+=" --hidden"; 326 | shift ;; 327 | -t|--tree-only) 328 | tree_only="true"; 329 | shift ;; 330 | -q|--quiet) 331 | QUIET="true"; 332 | NOCLIP="false"; 333 | shift ;; 334 | -p|--print) 335 | QUIET="false"; 336 | NOCLIP="false"; 337 | shift ;; 338 | -P|--print-only) 339 | QUIET="false"; 340 | NOCLIP="true"; 341 | shift ;; 342 | --debug) 343 | DEBUG="true"; 344 | shift ;; 345 | *) 346 | targets+=("$1"); 347 | shift ;; 348 | esac 349 | done 350 | 351 | detect_os 352 | 353 | # Interactive mode if no targets 354 | if [ ${#targets[@]} -eq 0 ]; then 355 | debug "Starting interactive mode" 356 | if check_dependencies; then 357 | debug "Running fzf selection" 358 | local selected 359 | selected=$(run_fzf "$fd_opts") 360 | 361 | if [ -n "$selected" ]; then 362 | debug "Processing selection" 363 | while IFS= read -r line; do 364 | [ -n "$line" ] && targets+=("$line") 365 | done <<< "$selected" 366 | else 367 | debug "No selection made" 368 | exit 0 369 | fi 370 | else 371 | exit 1 372 | fi 373 | fi 374 | 375 | if [ ${#targets[@]} -gt 0 ]; then 376 | debug "Processing ${#targets[@]} targets" 377 | process_targets "$fd_opts" "${targets[@]}" 378 | fi 379 | } 380 | 381 | main "$@" 382 | --------------------------------------------------------------------------------