Skip to content

Add clipboard support and fix windows paths #66

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,22 @@ This will output the contents of every file, with each file preceded by its rela
find . -name "*.py" -print0 | files-to-prompt --null
```

- `-C/--copy`: Copy the output to the clipboard instead of printing to stdout. Useful for quickly getting file contents ready to paste into an LLM chat interface.

```bash
files-to-prompt path/to/directory --copy
```

This option cannot be used together with `-o/--output`. If the clipboard operation fails, the output will be printed to stdout as a fallback.

Using `-C/--copy` requires the optional `pyperclip` dependency:

```bash
uv pip install 'files-to-prompt[clipboard]'
```

On Linux you also need `xclip` or `xsel`, and on macOS the standard `pbcopy` utility must be available.

### Example

Suppose you have a directory structure like this:
Expand Down
79 changes: 69 additions & 10 deletions files_to_prompt/cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import os
import sys
from fnmatch import fnmatch
from io import StringIO

import click

Expand All @@ -24,6 +25,11 @@
}


def norm_path(p: str) -> str:
"""Return path with forward slashes to ensure stable, cross-platform output."""
return p.replace(os.sep, "/")


def should_ignore(path, gitignore_rules):
for rule in gitignore_rules:
if fnmatch(os.path.basename(path), rule):
Expand Down Expand Up @@ -53,12 +59,13 @@ def add_line_numbers(content):


def print_path(writer, path, content, cxml, markdown, line_numbers):
p = norm_path(path)
if cxml:
print_as_xml(writer, path, content, line_numbers)
print_as_xml(writer, p, content, line_numbers)
elif markdown:
print_as_markdown(writer, path, content, line_numbers)
print_as_markdown(writer, p, content, line_numbers)
else:
print_default(writer, path, content, line_numbers)
print_default(writer, p, content, line_numbers)


def print_default(writer, path, content, line_numbers):
Expand Down Expand Up @@ -113,11 +120,11 @@ def process_path(
):
if os.path.isfile(path):
try:
with open(path, "r") as f:
with open(path, "r", encoding="utf-8") as f:
print_path(writer, path, f.read(), claude_xml, markdown, line_numbers)
except UnicodeDecodeError:
warning_message = f"Warning: Skipping file {path} due to UnicodeDecodeError"
click.echo(click.style(warning_message, fg="red"), err=True)
warning_message = f"Warning: Skipping file {norm_path(path)} due to UnicodeDecodeError"
click.echo(warning_message)
elif os.path.isdir(path):
for root, dirs, files in os.walk(path):
if not include_hidden:
Expand Down Expand Up @@ -156,7 +163,7 @@ def process_path(
for file in sorted(files):
file_path = os.path.join(root, file)
try:
with open(file_path, "r") as f:
with open(file_path, "r", encoding="utf-8") as f:
print_path(
writer,
file_path,
Expand All @@ -167,9 +174,9 @@ def process_path(
)
except UnicodeDecodeError:
warning_message = (
f"Warning: Skipping file {file_path} due to UnicodeDecodeError"
f"Warning: Skipping file {norm_path(file_path)} due to UnicodeDecodeError"
)
click.echo(click.style(warning_message, fg="red"), err=True)
click.echo(warning_message)


def read_paths_from_stdin(use_null_separator):
Expand Down Expand Up @@ -217,6 +224,13 @@ def read_paths_from_stdin(use_null_separator):
type=click.Path(writable=True),
help="Output to a file instead of stdout",
)
@click.option(
"copy_to_clipboard",
"-C",
"--copy",
is_flag=True,
help="Copy the output to clipboard instead of stdout",
)
@click.option(
"claude_xml",
"-c",
Expand Down Expand Up @@ -257,6 +271,7 @@ def cli(
markdown,
line_numbers,
null,
copy_to_clipboard,
):
"""
Takes one or more paths to files or directories and outputs every file,
Expand Down Expand Up @@ -302,10 +317,23 @@ def cli(
# Combine paths from arguments and stdin
paths = [*paths, *stdin_paths]

# If both -C and -o are provided, -o wins but print a note for the user
if copy_to_clipboard and output_file:
click.echo(
"Note: -o/--output overrides -C/--copy; writing output to file only.",
err=True,
)
copy_to_clipboard = False # Disable clipboard behaviour

gitignore_rules = []
writer = click.echo
fp = None
if output_file:
clipboard_buffer = None

if copy_to_clipboard:
clipboard_buffer = StringIO()
writer = lambda s: print(s, file=clipboard_buffer)
elif output_file:
fp = open(output_file, "w", encoding="utf-8")
writer = lambda s: print(s, file=fp)
for path in paths:
Expand All @@ -330,5 +358,36 @@ def cli(
)
if claude_xml:
writer("</documents>")

if copy_to_clipboard:
content = clipboard_buffer.getvalue()

try:
# Lazy import so that pyperclip remains an optional dependency
import pyperclip # type: ignore
except ImportError as exc:
raise click.ClickException(
"The -C/--copy option requires the optional 'pyperclip' package. "
"Install it with 'pip install files-to-prompt[clipboard]' or "
"re-run without -C/--copy."
) from exc

try:
pyperclip.copy(content)
click.echo("Output copied to clipboard")
except Exception as e:
# Provide additional platform-specific guidance
suggestion = ""
if sys.platform.startswith("linux"):
suggestion = " (hint: install 'xclip' or 'xsel')"
elif sys.platform == "darwin":
suggestion = " (make sure the 'pbcopy' utility is available)"

click.echo(
f"Failed to copy to clipboard: {e}{suggestion}. Output follows:",
err=True,
)
click.echo(content)

if fp:
fp.close()
8 changes: 5 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ dependencies = [
"click"
]

[project.optional-dependencies]
clipboard = [
"pyperclip"
]

[project.urls]
Homepage = "https://github.com/simonw/files-to-prompt"
Changelog = "https://github.com/simonw/files-to-prompt/releases"
Expand All @@ -21,6 +26,3 @@ CI = "https://github.com/simonw/files-to-prompt/actions"

[project.entry-points.console_scripts]
files-to-prompt = "files_to_prompt.cli:cli"

[project.optional-dependencies]
test = ["pytest"]
133 changes: 127 additions & 6 deletions tests/test_files_to_prompt.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import os
import pytest
import re
from unittest.mock import patch, MagicMock

from click.testing import CliRunner

Expand Down Expand Up @@ -246,15 +247,14 @@ def test_binary_file_warning(tmpdir):
result = runner.invoke(cli, ["test_dir"])
assert result.exit_code == 0

stdout = result.stdout
stderr = result.stderr
output = result.output.replace("\\", "/")

assert "test_dir/text_file.txt" in stdout
assert "This is a text file" in stdout
assert "\ntest_dir/binary_file.bin" not in stdout
assert "test_dir/text_file.txt" in output
assert "This is a text file" in output
assert "\ntest_dir/binary_file.bin" not in output
assert (
"Warning: Skipping file test_dir/binary_file.bin due to UnicodeDecodeError"
in stderr
in output
)


Expand Down Expand Up @@ -439,3 +439,124 @@ def test_markdown(tmpdir, option):
"`````\n"
)
assert expected.strip() == actual.strip()


@pytest.mark.parametrize("option", ("-C", "--copy"))
def test_copy_to_clipboard(tmpdir, option):
runner = CliRunner()
with tmpdir.as_cwd():
os.makedirs("test_dir")
with open("test_dir/file1.txt", "w") as f:
f.write("Contents of file1")
with open("test_dir/file2.txt", "w") as f:
f.write("Contents of file2")

# Test successful copy
with patch('pyperclip.copy') as mock_copy:
result = runner.invoke(cli, ["test_dir", option])
assert result.exit_code == 0
assert "Output copied to clipboard" in result.output

# Verify pyperclip.copy was called with the correct content
mock_copy.assert_called_once()
copied_content = mock_copy.call_args[0][0]
assert "test_dir/file1.txt" in copied_content
assert "Contents of file1" in copied_content
assert "test_dir/file2.txt" in copied_content
assert "Contents of file2" in copied_content


def test_copy_to_clipboard_with_formats(tmpdir):
runner = CliRunner()
with tmpdir.as_cwd():
os.makedirs("test_dir")
with open("test_dir/file.py", "w") as f:
f.write("print('hello')")

# Test with markdown format
with patch('pyperclip.copy') as mock_copy:
result = runner.invoke(cli, ["test_dir", "-C", "--markdown"])
assert result.exit_code == 0
assert "Output copied to clipboard" in result.output

copied_content = mock_copy.call_args[0][0]
assert "```python" in copied_content
assert "print('hello')" in copied_content
assert "```" in copied_content

# Test with XML format
with patch('pyperclip.copy') as mock_copy:
result = runner.invoke(cli, ["test_dir", "-C", "--cxml"])
assert result.exit_code == 0
assert "Output copied to clipboard" in result.output

copied_content = mock_copy.call_args[0][0]
assert "<documents>" in copied_content
assert "<document index=" in copied_content
assert "<source>test_dir/file.py</source>" in copied_content
assert "</documents>" in copied_content


def test_copy_to_clipboard_failure(tmpdir):
runner = CliRunner()
with tmpdir.as_cwd():
os.makedirs("test_dir")
with open("test_dir/file.txt", "w") as f:
f.write("Test content")

# Test clipboard failure
with patch('pyperclip.copy') as mock_copy:
mock_copy.side_effect = Exception("Clipboard not available")
result = runner.invoke(cli, ["test_dir", "-C"])
assert result.exit_code == 0
assert "Failed to copy to clipboard: Clipboard not available" in result.output
assert "Output follows:" in result.output
# When clipboard fails, content should be printed to stdout
assert "test_dir/file.txt" in result.output
assert "Test content" in result.output


def test_copy_and_output_conflict(tmpdir):
runner = CliRunner()
with tmpdir.as_cwd():
os.makedirs("test_dir")
with open("test_dir/file.txt", "w") as f:
f.write("Test content")

# Test that -C and -o together produce an error
result = runner.invoke(cli, ["test_dir", "-C", "-o", "output.txt"])
assert result.exit_code == 0
combined = result.output
assert "Note: -o/--output overrides -C/--copy" in combined
# Clipboard should not be invoked
assert "Output copied to clipboard" not in combined


def test_copy_clipboard_basic(tmpdir):
"""Basic clipboard copy succeeds when pyperclip is available"""
runner = CliRunner()
with tmpdir.as_cwd():
os.makedirs("test_dir")
with open("test_dir/file1.txt", "w") as f:
f.write("Contents of file1")
with open("test_dir/file2.txt", "w") as f:
f.write("Contents of file2")

# Provide a stub pyperclip if it's not installed
import types, sys as _sys
if 'pyperclip' not in _sys.modules:
stub = types.ModuleType('pyperclip')
def _copy(_: str):
pass
stub.copy = _copy
_sys.modules['pyperclip'] = stub

with patch('pyperclip.copy') as mock_copy:
# Simulate successful copy on all platforms
result = runner.invoke(cli, ["test_dir", "-C"])
assert result.exit_code == 0
assert "Output copied to clipboard" in result.output
mock_copy.assert_called_once()

# The actual platform-specific handling is done by pyperclip
# We just ensure our code calls it correctly