Skip to content

Instantly share code, notes, and snippets.

@cofob
Last active August 8, 2025 15:20
Show Gist options
  • Select an option

  • Save cofob/e45419ff1eb5892390e049e62aa3247e to your computer and use it in GitHub Desktop.

Select an option

Save cofob/e45419ff1eb5892390e049e62aa3247e to your computer and use it in GitHub Desktop.
ai-copy: Smart code copying for AI with proper paths and .gitignore in one command.

🤖 AI Copy - Smart Code Copier for AI Prompts

Copy code to clipboard with properly formatted relative paths—perfect for sending to AI assistants. This tool preserves your project's directory structure while respecting .gitignore rules and allowing custom messages.

✨ Features

  • AI-Ready Format: Files are prefixed with ./relative/path headers, followed by their content.
  • Git Aware: Respects .gitignore rules by default (use --no-ignore to override).
  • Robust and Safe: Uses Python's pathlib and argparse for reliable cross-platform path and argument handling.
  • Efficient: Gets all ignored files from Git in a single, fast operation.
  • Message Support: Add context with the -m flag (pass text directly or open $EDITOR).
  • Verbose Debugging: See exactly what's happening with the -v flag.
  • Cross-Platform: Works on macOS and Linux out of the box.
  • Dependency-Free: Runs with a standard Python 3 installation—no pip installs required.
  • Auto-Detects Clipboard: Automatically uses pbcopy (macOS) or xclip/xsel/wl-copy (Linux).

🚀 Quick Start

# Copy all Python files to the clipboard
ai-copy -name '*.py'

# Add a custom message, which appears at the end of the clipboard content
ai-copy -name '*.py' -m "Please review these core modules"

# Open your default text editor to write a longer, multi-line message
ai-copy -m

# See exactly which files will be copied and why
ai-copy -v -- src -name '*.js'

📦 Installation

Manual Install

  1. Save the Python script to a file named ai-copy inside a directory that's in your PATH, like ~/.local/bin/.
  2. Make it executable:
    chmod +x ~/.local/bin/ai-copy

Requirements

  • Python 3.6+
  • Git (for .gitignore support)
  • A clipboard utility:
    • macOS: pbcopy (pre-installed)
    • Linux: xclip or xsel (install via your package manager, e.g., sudo apt-get install xclip) for X11
    • Linux: wl-copy for Wayland - install

📖 Usage Documentation

Basic Syntax

The script uses a standard find-like syntax.

ai-copy [OPTIONS] -- [PATH] [FIND_EXPRESSIONS...]

Note: It's best practice to use -- to separate the script's options (like -v or -m) from the path and find expressions.

CLI Options

Flag Description
--no-ignore Disable .gitignore filtering (include all files).
-m [MESSAGE] Add a custom message at the end. If no message is given, opens $EDITOR.
-v, --verbose Enable verbose debug logging to see step-by-step execution.
-h, --help Show the help message and exit.

Common Examples

Copy specific file types

# Copy all Python files from the current directory
ai-copy . -name '*.py'

# Copy all JavaScript and TypeScript files from the 'src' directory
ai-copy src -- -type f \( -name '*.js' -o -name '*.ts' \)

Add context with messages

# Add a short, direct message
ai-copy -m "Implement the new authentication logic" . -- -name '*.py'

# Combine options (place them before the --)
ai-copy -v -m "Fix the caching issue" src/lib -- -name 'cache.js'

Advanced filtering using find

# Copy Python files between 1KB and 1MB
ai-copy . -- \( -size +1k -a -size -1M \) -name '*.py'

🔍 Verbose Debugging Example

The -v flag shows you exactly how the script makes its decisions.

$ ai-copy -v . -- -name '*.py'
[DEBUG] Search base: /Users/cofob/Development/project
[DEBUG] Find filters: -name *.py
[DEBUG] Running command: find /Users/cofob/Development/project -type d -name .git -prune -o -name *.py -type f -print0
[DEBUG] Found 12 candidate files.
[DEBUG] Git repository found (root: /Users/cofob/Development/project).
[DEBUG] Identifying ignored files from 12 candidates.
[DEBUG] Kept 2 files after .gitignore filtering.
[DEBUG] Formatting output for clipboard.
[DEBUG] Formatted: './main.py'
[DEBUG] Formatted: './src/utils.py'
[DEBUG] Copied 14582 bytes to clipboard.
Copied 2 files to clipboard

💡 Why Use This Instead of cat?

Method Relative Paths Git Ignore Special Chars Message Support
cat *.py
find + xargs cat
ai-copy

When sending code to AI assistants, context is everything. This tool ensures the AI sees the code exactly as it's structured in your project.


⚙️ Robust Python Implementation

This script was rewritten from Bash to Python for improved robustness, portability, and maintainability.

Safe Argument Parsing

It uses Python's standard argparse library to handle command-line flags and arguments, which prevents the ambiguities and errors common in shell script parsing.

Efficient Git Ignore Processing

The script uses the most reliable method to respect your .gitignore files.

  1. It gets a complete list of all candidate files from find.
  2. It makes a single call to git check-ignore to get a list of all files that are ignored.
  3. It then performs a set difference in Python to get the final list of files to copy.

This approach is both highly efficient and more stable than other methods.

Modern and Dependency-Free

Written in modern Python 3, the script uses standard libraries like pathlib for OS-agnostic path handling and logging for clear debugging. It requires no external packages from pip.


🐛 Troubleshooting

"Copied 0 files" but files exist

  • Check your .gitignore file: The files are likely being excluded. You can confirm this by running with the --no-ignore flag: ai-copy --no-ignore . -- -name '*.py'
  • Verify with verbose mode: ai-copy -v . -- -name '*.py' will show you which files were found and which were filtered out.

Clipboard not working

  • Linux Users: Make sure you have xclip or xsel installed. You can install it with your system's package manager:
    # For Debian/Ubuntu
    sudo apt-get install xclip
    
    # For Fedora
    sudo dnf install xclip

📜 License

MIT License

Made with ❤️ for developers sending code to AI

Created by cofob - inspired by real-world frustration with messy AI prompts

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
AI Copy - Smart Code Copier for AI Prompts
A script to find files, format their contents with relative path headers,
and copy the result to the clipboard. It's designed to make providing
code context to AI assistants easy and accurate.
Features:
- AI-Ready Format: Files are prefixed with `./relative/path` headers.
- Git Aware: Automatically respects .gitignore rules.
- Safe & Robust: Handles filenames with spaces or special characters.
- Efficient: Uses batch processing for filtering thousands of files quickly.
- Message Support: Allows adding a custom message via command line or a text editor.
- Cross-Platform: Works on macOS and Linux without modification.
"""
import argparse
import logging
import os
import subprocess
import sys
import tempfile
import shutil
from pathlib import Path
# --- Utility Functions ---
def _get_clipboard_command():
"""Detects the appropriate clipboard command for the system."""
if sys.platform == "darwin":
return "pbcopy"
for cmd in ["xclip", "xsel", "wl-copy"]:
if shutil.which(cmd):
return cmd
return None
def copy_to_clipboard(text: str):
"""Copies the given text to the system clipboard."""
command = _get_clipboard_command()
if not command:
logging.error("No clipboard command found (requires pbcopy, xclip, or xsel).")
logging.info("--- SCRIPT OUTPUT ---")
print(text) # Print to stdout as a fallback
logging.info("--- END SCRIPT OUTPUT ---")
return
try:
args = [command]
if command in ["xclip", "xsel"]:
args.extend(["-selection", "clipboard"])
subprocess.run(args, input=text.encode("utf-8"), check=True)
except (subprocess.CalledProcessError, FileNotFoundError) as e:
logging.error(f"Clipboard command '{command}' failed: {e}")
logging.info("--- SCRIPT OUTPUT ---")
print(text)
logging.info("--- END SCRIPT OUTPUT ---")
def get_editor_message() -> str:
"""Opens the user's default editor to get a multiline message."""
editor = os.getenv("EDITOR", "vi")
try:
with tempfile.NamedTemporaryFile(mode="w+", suffix=".txt", delete=True) as tmpfile:
tmpfile.write(
"# Enter your message above this line.\n"
"# Lines starting with '#' will be ignored.\n"
"# Save and close the editor to continue, or close without saving to abort.\n"
)
tmpfile.flush()
subprocess.run([editor, tmpfile.name], check=True)
tmpfile.seek(0)
message_lines = [
line for line in tmpfile.read().splitlines()
if not line.strip().startswith("#")
]
final_message = "\n".join(message_lines).strip()
if not final_message:
logging.debug("Aborted: No message entered.")
return ""
return final_message
except (FileNotFoundError, subprocess.CalledProcessError) as e:
logging.error(f"Editor '{editor}' failed: {e}")
sys.exit(1)
except Exception as e:
logging.error(f"An unexpected error occurred while using the editor: {e}")
sys.exit(1)
# --- Core Logic ---
def find_files(base_dir: Path, find_expressions: list) -> list[Path]:
"""
Uses the `find` command to locate files, handling special characters safely.
"""
logging.debug(f"Search base: {base_dir}")
if find_expressions:
logging.debug(f"Find filters: {' '.join(find_expressions)}")
else:
logging.warning("No find filters specified - selecting ALL files.")
cmd = ["find", str(base_dir), "-type", "d", "-name", ".git", "-prune", "-o"]
if find_expressions:
cmd.extend(find_expressions)
cmd.extend(["-type", "f", "-print0"])
logging.debug(f"Running command: {' '.join(cmd)}")
try:
result = subprocess.run(cmd, capture_output=True, check=True)
found_paths = [Path(p) for p in result.stdout.decode("utf-8").strip("\0").split("\0") if p]
logging.debug(f"Found {len(found_paths)} candidate files.")
return found_paths
except subprocess.CalledProcessError as e:
logging.error(f"'find' command failed with exit code {e.returncode}.")
logging.error(f"Stderr: {e.stderr.decode('utf-8').strip()}")
return []
except FileNotFoundError:
logging.error("'find' command not found. Please ensure it is in your PATH.")
return []
def filter_ignored_files(paths: list[Path], base_dir: Path) -> list[Path]:
"""
Filters a list of file paths by identifying ignored files and taking the set difference.
"""
try:
git_check_proc = subprocess.run(
["git", "-C", str(base_dir), "rev-parse", "--is-inside-work-tree"],
capture_output=True, text=True
)
if git_check_proc.returncode != 0 or git_check_proc.stdout.strip() != 'true':
logging.debug("Not a git repository. Skipping .gitignore filtering.")
return paths
repo_root_proc = subprocess.run(
["git", "-C", str(base_dir), "rev-parse", "--show-toplevel"],
capture_output=True, text=True, check=True
)
repo_root = Path(repo_root_proc.stdout.strip())
logging.debug(f"Git repository found (root: {repo_root}).")
except (FileNotFoundError, subprocess.CalledProcessError):
logging.debug("Git not found or not a git repo. Skipping .gitignore filtering.")
return paths
if not paths:
logging.debug("No candidate files to filter.")
return []
candidate_paths_set = set(paths)
logging.debug(f"Identifying ignored files from {len(paths)} candidates.")
relative_paths = [str(p.relative_to(repo_root)) for p in paths]
stdin_data = "\0".join(relative_paths) + "\0"
cmd = ["git", "-C", str(repo_root), "check-ignore", "--stdin", "-z", "--no-index"]
result = subprocess.run(cmd, input=stdin_data.encode("utf-8"), capture_output=True, check=False)
if result.returncode not in [0, 1]:
logging.error(f"Git check-ignore failed with fatal error {result.returncode}")
logging.error(f"Stderr: {result.stderr.decode('utf-8').strip()}")
return paths
ignored_paths_set = set()
if result.stdout:
ignored_relative_paths = result.stdout.decode("utf-8").strip("\0").split("\0")
for rel_path in ignored_relative_paths:
if rel_path:
ignored_paths_set.add(repo_root / rel_path)
final_paths = sorted(list(candidate_paths_set - ignored_paths_set))
logging.debug(f"Kept {len(final_paths)} files after .gitignore filtering.")
return final_paths
def main():
"""Main execution function."""
parser = argparse.ArgumentParser(
description="Copies file contents to the clipboard, formatted for AI prompts.",
epilog="""
Examples:
%(prog)s -name '*.py' # Copy all Python files
%(prog)s -v -- src -type f -name '*.js' --no-ignore # Use '--' to separate script flags from find expressions
%(prog)s -m "Please review these changes." -v # Add a custom message with verbose output
%(prog)s -m # Open $EDITOR to write a message
""",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--no-ignore", action="store_true", help="Disable .gitignore filtering and include all found files.")
parser.add_argument("-m", "--message", nargs="?", const="_EDITOR_", default=None, help="Add a custom message. If no message is provided, opens $EDITOR.")
parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose debug logging.")
parser.add_argument("path", nargs="?", default=".", help="Directory to search (default: current directory).")
parser.add_argument("find_expressions", nargs=argparse.REMAINDER, help="Standard find(1) expressions (e.g., -name '*.py').")
args = parser.parse_args()
# Configure logging
log_format = "[%(levelname)s] %(message)s"
log_level = logging.DEBUG if args.verbose else logging.WARNING
logging.basicConfig(level=log_level, format=log_format, stream=sys.stderr)
script_flags = {'-v', '--verbose', '-m', '--message', '--no-ignore', '-h', '--help'}
misplaced_flags = set(args.find_expressions) & script_flags
if misplaced_flags:
logging.warning("Script options found after the path or find expressions.")
logging.warning(f" Misplaced options: {', '.join(misplaced_flags)}")
logging.warning(" To fix this, place all script options (like -v, -m) BEFORE the path.")
logging.warning(" Example: ai-copy -v -- . -name '*.py'")
args.find_expressions = [expr for expr in args.find_expressions if expr not in misplaced_flags]
base_dir = Path(args.path).resolve()
if not base_dir.is_dir():
logging.critical(f"Error: Invalid directory '{args.path}'")
sys.exit(1)
custom_message = ""
if args.message is not None:
if args.message == "_EDITOR_":
custom_message = get_editor_message()
else:
custom_message = args.message
if custom_message:
logging.debug(f"Custom message loaded ({len(custom_message)} bytes).")
files_to_process = find_files(base_dir, args.find_expressions)
if not args.no_ignore:
files_to_process = filter_ignored_files(files_to_process, base_dir)
if not files_to_process:
print("Found 0 files to copy.", file=sys.stderr)
sys.exit(0)
logging.debug("Formatting output for clipboard.")
output_parts = []
for fpath in sorted(files_to_process):
try:
rel_path = fpath.relative_to(base_dir)
content = fpath.read_text(encoding="utf-8", errors="ignore")
output_parts.append(f"\n\n./{rel_path}\n")
output_parts.append(content)
logging.debug(f"Formatted: './{rel_path}'")
except Exception as e:
logging.warning(f"Skipping file {fpath} due to read error: {e}")
final_output = "".join(output_parts)
if custom_message:
final_output += f"\n\n{custom_message}"
logging.debug("Appended custom message.")
if final_output.strip():
copy_to_clipboard(final_output.lstrip())
clipboard_size = len(final_output.lstrip().encode('utf-8'))
logging.debug(f"Copied {clipboard_size} bytes to clipboard.")
# Final status message printed to stderr so it doesn't interfere with stdout
status = f"Copied {len(files_to_process)} files to clipboard"
if custom_message:
status += " (with message)"
msg_preview = (custom_message[:30].replace("\n", " ") + "..." if len(custom_message) > 30 else custom_message.replace("\n", " "))
status += f"\nMessage preview: '{msg_preview}'"
print(status, file=sys.stderr)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment