#!/usr/bin/env python3 """ folder2text - Convert a folder's contents to a single text file for AI consumption. """ import os import sys import argparse import fnmatch import shutil import subprocess from pathlib import Path from datetime import datetime # Extensions that are almost certainly binary/non-readable DEFAULT_SKIP_EXTENSIONS = { # Images ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".svg", ".webp", ".tiff", # Audio/Video ".mp3", ".mp4", ".wav", ".avi", ".mov", ".mkv", ".flac", ".ogg", # Archives ".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar", # Compiled / Binary ".exe", ".dll", ".so", ".dylib", ".bin", ".o", ".a", ".class", ".pyc", # Office / Documents ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", # Fonts ".ttf", ".otf", ".woff", ".woff2", # Database ".db", ".sqlite", ".sqlite3", # Other binary ".iso", ".img", ".dmg", } # Directories to skip by default DEFAULT_SKIP_DIRS = { ".git", ".svn", ".hg", "__pycache__", ".pytest_cache", "node_modules", ".venv", "venv", "env", ".idea", ".vscode", "dist", "build", ".next", ".nuxt", } # File name/glob patterns to skip by default (sensitive files) DEFAULT_SKIP_PATTERNS = { ".env", ".env.*", # .env.local, .env.production, etc. "*.pem", # TLS/SSL private keys "*.key", # Generic private keys "*.p12", # PKCS#12 key bundles "*.pfx", # Same, Windows name "id_rsa", # SSH private keys "id_ed25519", "id_ecdsa", "id_dsa", ".netrc", # FTP/HTTP credentials ".htpasswd", # Apache password files "credentials", # AWS credentials file (name only, no ext) } # Files matching these patterns are never blocked, even if they also match a # sensitive pattern — e.g. .env.example matches .env.* but is safe to share. DEFAULT_ALLOW_PATTERNS = { "*.example", # .env.example, config.example, etc. "*.sample", # .env.sample, settings.sample, etc. "*.template", # .env.template, etc. } # Patterns that are sensitive by default but can be re-included with --allow-pattern _SENSITIVE_PATTERN_NOTE = ( "Sensitive file patterns (skipped by default, override with --allow-pattern):" ) def detect_clipboard_cmd(debug: bool = False) -> list[str] | None: """Return the clipboard copy command for the current platform, or None.""" import platform system = platform.system() if debug: print(f" [clipboard] platform: {system}", file=sys.stderr) if system == "Darwin": return ["pbcopy"] if system == "Windows": return ["clip"] has_x11 = bool(os.environ.get("DISPLAY")) has_wayland = bool(os.environ.get("WAYLAND_DISPLAY")) if debug: print(f" [clipboard] DISPLAY={os.environ.get('DISPLAY')!r}", file=sys.stderr) print(f" [clipboard] WAYLAND_DISPLAY={os.environ.get('WAYLAND_DISPLAY')!r}", file=sys.stderr) candidates = [] if has_x11: candidates += [ ("xclip", ["-selection", "clipboard"]), ("xsel", ["--clipboard", "--input"]), ] if has_wayland: candidates += [("wl-copy", [])] if not candidates: # Neither env var set — try everything candidates = [ ("xclip", ["-selection", "clipboard"]), ("xsel", ["--clipboard", "--input"]), ("wl-copy", []), ] for cmd, args in candidates: found = shutil.which(cmd) if debug: print(f" [clipboard] {cmd}: {'found at ' + found if found else 'not found'}", file=sys.stderr) if found: return [cmd] + args return None def is_binary_file(filepath: Path, sample_size: int = 8192) -> bool: """Heuristically check if a file is binary.""" try: with open(filepath, "rb") as f: chunk = f.read(sample_size) # If null bytes exist, it's almost certainly binary if b"\x00" in chunk: return True # Try decoding as UTF-8 chunk.decode("utf-8") return False except (UnicodeDecodeError, OSError): return True def format_size(size_bytes: int) -> str: """Human-readable file size.""" for unit in ("B", "KB", "MB", "GB"): if size_bytes < 1024: return f"{size_bytes:.1f} {unit}" size_bytes /= 1024 return f"{size_bytes:.1f} TB" def matches_any_pattern(name: str, rel: str, patterns: set[str] | list[str]) -> bool: """Return True if the filename or relative path matches any of the given glob patterns.""" for pat in patterns: if fnmatch.fnmatch(name, pat) or fnmatch.fnmatch(rel, pat): return True return False def collect_files( root: Path, skip_extensions: set[str], include_extensions: set[str] | None, skip_dirs: set[str], extra_skip_patterns: list[str], sensitive_patterns: set[str], allow_patterns: set[str], max_file_size: int, verbose: bool = False, ) -> tuple[list[Path], int]: """Walk the directory and return (included_files, sensitive_skipped_count).""" files = [] sensitive_skipped = 0 all_skip_patterns = list(extra_skip_patterns) # user glob patterns (dirs + files) for dirpath, dirnames, filenames in os.walk(root): current = Path(dirpath) # Prune skipped directories in-place so os.walk doesn't descend into them dirnames[:] = [ d for d in dirnames if d not in skip_dirs and not matches_any_pattern(d, d, all_skip_patterns) ] dirnames.sort() for filename in sorted(filenames): filepath = current / filename rel = str(filepath.relative_to(root)) # Skip by user-supplied glob pattern if matches_any_pattern(filename, rel, all_skip_patterns): continue # Skip sensitive files — unless the file is explicitly allowed or # matches a built-in safe pattern (e.g. *.example, *.sample) if (matches_any_pattern(filename, rel, sensitive_patterns) and not matches_any_pattern(filename, rel, DEFAULT_ALLOW_PATTERNS) and not matches_any_pattern(filename, rel, allow_patterns)): if verbose: print(f" [skip secret] {rel}", file=sys.stderr) sensitive_skipped += 1 continue ext = filepath.suffix.lower() # Inclusion filter takes priority if specified if include_extensions is not None: if ext not in include_extensions: continue else: # Otherwise apply default skip list if ext in skip_extensions: continue # Size guard try: size = filepath.stat().st_size except OSError: continue if size > max_file_size: continue files.append(filepath) return files, sensitive_skipped def build_tree(root: Path, files: list[Path]) -> str: """Build an ASCII directory tree of included files with proper folder nodes.""" # Collect every node: intermediate dirs + files, as tuples of parts nodes: set[tuple[str, ...]] = set() for f in files: rel = f.relative_to(root) for i in range(len(rel.parts)): nodes.add(rel.parts[: i + 1]) sorted_nodes = sorted(nodes) def is_last(parts: tuple[str, ...]) -> bool: parent = parts[:-1] siblings = [n for n in sorted_nodes if n[:-1] == parent] return siblings[-1] == parts lines = [str(root.name) + "/"] for parts in sorted_nodes: depth = len(parts) - 1 prefix = "" for i in range(depth): ancestor = parts[: i + 1] prefix += " " if is_last(ancestor) else "│ " connector = "└── " if is_last(parts) else "├── " name = parts[-1] is_dir = any(n[: len(parts)] == parts and len(n) > len(parts) for n in sorted_nodes) lines.append(f"{prefix}{connector}{name}{'/' if is_dir else ''}") return "\n".join(lines) def _output( text: str, output: str | None, copy: bool, clipboard_cmd_override: str | None, verbose: bool, label: str, skipped: int, sensitive_skipped: int, ) -> None: """Write text to stdout, a file, or the clipboard.""" if copy: if clipboard_cmd_override: clipboard_cmd = clipboard_cmd_override.split() else: clipboard_cmd = detect_clipboard_cmd(debug=verbose) if not clipboard_cmd: print( "Error: no working clipboard tool found.\n" " Run with -v to see what was detected.\n" " Or specify one manually: --clipboard-cmd 'xclip -selection clipboard'\n" " Install options:\n" " sudo apt install xclip # Ubuntu/Debian, X11\n" " sudo apt install wl-clipboard # Ubuntu/Debian, Wayland\n" " sudo dnf install xclip # Fedora, X11\n" " sudo dnf install wl-clipboard # Fedora, Wayland", file=sys.stderr, ) sys.exit(1) char_count = len(text) try: subprocess.run(clipboard_cmd, input=text.encode("utf-8"), check=True) except subprocess.CalledProcessError as e: print(f"Error: clipboard command failed: {e}", file=sys.stderr) sys.exit(1) approx_tokens = char_count // 4 skipped_str = f", {skipped} skipped" if skipped else "" sensitive_str = f", {sensitive_skipped} secret file(s) protected" if sensitive_skipped else "" print(f"Copied to clipboard! {label}, ~{char_count:,} chars (~{approx_tokens:,} tokens){skipped_str}{sensitive_str}.") elif output: with open(output, "w", encoding="utf-8", errors="replace") as f: f.write(text) print(f"Done. Written to '{output}'.") else: print(text, end="") def convert( folder: str, output: str | None, skip_extensions: set[str], include_extensions: set[str] | None, skip_dirs: set[str], extra_skip_patterns: list[str], sensitive_patterns: set[str], allow_patterns: set[str], max_file_size_mb: float, show_tree: bool, verbose: bool, separator: str, copy: bool = False, clipboard_cmd_override: str | None = None, minimal: bool = False, ) -> None: root = Path(folder).resolve() # Single-file mode: if the path points to a file, just copy/output that file directly if root.is_file(): if is_binary_file(root): print(f"Error: '{folder}' appears to be a binary file.", file=sys.stderr) sys.exit(1) try: content = root.read_text(encoding="utf-8", errors="replace") except OSError as e: print(f"Error reading '{folder}': {e}", file=sys.stderr) sys.exit(1) _output(content, output, copy, clipboard_cmd_override, verbose, label=f"1 file ({format_size(root.stat().st_size)})", skipped=0, sensitive_skipped=0) return if not root.is_dir(): print(f"Error: '{folder}' is not a file or directory.", file=sys.stderr) sys.exit(1) max_bytes = int(max_file_size_mb * 1024 * 1024) files, sensitive_skipped = collect_files( root, skip_extensions, include_extensions, skip_dirs, extra_skip_patterns, sensitive_patterns, allow_patterns, max_bytes, verbose=verbose, ) if not files: print("No files matched the criteria. Nothing to output.", file=sys.stderr) sys.exit(0) buf = [] def emit(text: str) -> None: buf.append(text) # Header if not minimal: header_lines = [ f"# folder2text output", f"# Source : {root}", f"# Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", f"# Files : {len(files)}", ] emit("\n".join(header_lines) + "\n") if show_tree: emit("\n## Directory Tree\n\n```\n") emit(build_tree(root, files)) emit("\n```\n") skipped = 0 included = 0 for filepath in files: rel = filepath.relative_to(root) if is_binary_file(filepath): if verbose: print(f" [skip binary] {rel}", file=sys.stderr) skipped += 1 continue try: content = filepath.read_text(encoding="utf-8", errors="replace") except OSError as e: if verbose: print(f" [skip error ] {rel}: {e}", file=sys.stderr) skipped += 1 continue included += 1 size_str = format_size(filepath.stat().st_size) if separator == "markdown": ext = filepath.suffix.lstrip(".") header = f"## {rel}" if minimal else f"## {rel} ({size_str})" emit(f"\n\n{header}\n\n```{ext}\n{content}\n```\n") elif separator == "xml": attrs = f'path="{rel}"' if minimal else f'path="{rel}" size="{size_str}"' emit(f"\n\n\n{content}\n\n") else: # plain divider = "=" * 72 label = f" FILE: {rel}" if minimal else f" FILE: {rel} ({size_str})" emit(f"\n\n{divider}\n{label}\n{divider}\n\n{content}\n") if verbose: print(f" [ok ] {rel} ({size_str})", file=sys.stderr) # Footer if not minimal: emit(f"\n\n# End of folder2text output ({included} files included, {skipped} skipped)\n") _output("".join(buf), output, copy, clipboard_cmd_override, verbose, label=f"{included} files", skipped=skipped, sensitive_skipped=sensitive_skipped) def main() -> None: parser = argparse.ArgumentParser( prog="folder2text", description="Convert a folder's text files into a single document for AI consumption.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: # Basic usage - print to stdout folder2text ./my_project # Save to file folder2text ./my_project -o output.txt # Only include Python and Markdown files folder2text ./my_project --include .py .md # Skip test files and __pycache__ in addition to defaults folder2text ./my_project --skip-patterns "test_*" "*.min.js" # Skip extra extensions on top of defaults folder2text ./my_project --skip-ext .log .csv # Remove .svg from the default skip list (include SVGs) folder2text ./my_project --allow-ext .svg # Force-include a sensitive file that is normally protected folder2text ./my_project --allow-pattern .env.example # Use XML-style separators (good for structured AI prompts) folder2text ./my_project --format xml # Show directory tree at the top, verbose logging folder2text ./my_project --tree -v """, ) parser.add_argument("folder", help="Path to the folder to convert") parser.add_argument("-o", "--output", metavar="FILE", help="Write output to FILE instead of stdout") parser.add_argument("--include", nargs="+", metavar="EXT", help="Only include these extensions (e.g. .py .md). " "Overrides the default skip list entirely.") parser.add_argument("--skip-ext", nargs="+", metavar="EXT", help="Additional extensions to skip (e.g. .log .csv)") parser.add_argument("--allow-ext", nargs="+", metavar="EXT", help="Remove these extensions from the default skip list " "(e.g. .svg to include SVGs)") parser.add_argument("--skip-dirs", nargs="+", metavar="DIR", help="Additional directory names to skip") parser.add_argument("--skip-patterns", nargs="+", metavar="PATTERN", help="Glob patterns for files/dirs to skip (e.g. 'test_*' '*.min.js')") parser.add_argument("--allow-pattern", nargs="+", metavar="PATTERN", help="Remove these patterns from the default sensitive-file block list " "(e.g. .env.example to include example env files)") parser.add_argument("--max-size", type=float, default=1.0, metavar="MB", help="Skip files larger than this many MB (default: 1.0)") parser.add_argument("--format", choices=["plain", "markdown", "xml"], default="markdown", help="Output format: plain, markdown (default), or xml") parser.add_argument("--tree", action="store_true", help="Include an ASCII directory tree at the top of the output") parser.add_argument("-v", "--verbose", action="store_true", help="Print per-file status to stderr") parser.add_argument("-m", "--minimal", action="store_true", help="Output only filenames and content — no size, timestamps, headers or footers") parser.add_argument("-c", "--copy", action="store_true", help="Copy output directly to clipboard (auto-detects pbcopy/xclip/xsel/wl-copy)") parser.add_argument("--clipboard-cmd", metavar="CMD", help="Override clipboard command (e.g. --clipboard-cmd 'xclip -selection clipboard')") parser.add_argument("--list-skip-defaults", action="store_true", help="Print the default skip lists and exit") args = parser.parse_args() if args.list_skip_defaults: print("Default skipped extensions:") for ext in sorted(DEFAULT_SKIP_EXTENSIONS): print(f" {ext}") print("\nDefault skipped directories:") for d in sorted(DEFAULT_SKIP_DIRS): print(f" {d}") print(f"\n{_SENSITIVE_PATTERN_NOTE}") for p in sorted(DEFAULT_SKIP_PATTERNS): print(f" {p}") sys.exit(0) # Build effective skip set skip_ext = set(DEFAULT_SKIP_EXTENSIONS) if args.allow_ext: for e in args.allow_ext: skip_ext.discard(e if e.startswith(".") else f".{e}") if args.skip_ext: for e in args.skip_ext: skip_ext.add(e if e.startswith(".") else f".{e}") include_ext = None if args.include: include_ext = {e if e.startswith(".") else f".{e}" for e in args.include} skip_dirs = set(DEFAULT_SKIP_DIRS) if args.skip_dirs: skip_dirs.update(args.skip_dirs) # Build sensitive patterns set (can be narrowed with --allow-pattern) sensitive_patterns = set(DEFAULT_SKIP_PATTERNS) allow_patterns: set[str] = set(args.allow_pattern) if args.allow_pattern else set() # Direct removal of exact pattern strings the user passed verbatim for p in allow_patterns: sensitive_patterns.discard(p) convert( folder=args.folder, output=args.output, skip_extensions=skip_ext, include_extensions=include_ext, skip_dirs=skip_dirs, extra_skip_patterns=args.skip_patterns or [], sensitive_patterns=sensitive_patterns, allow_patterns=allow_patterns, max_file_size_mb=args.max_size, show_tree=args.tree, verbose=args.verbose, separator=args.format, copy=args.copy, clipboard_cmd_override=args.clipboard_cmd, minimal=args.minimal, ) if __name__ == "__main__": main()