Repository: simonw/files-to-prompt

Issue:
Error writing to output file on Windows
For some files on Windows, when writing the contents to the output file (-o prompt.txt), I get this error:
`UnicodeEncodeError: 'charmap' codec can't encode characters in position 27-28: character maps to <undefined>`

Seems to be an issue that is specific to windows python according to: https://stackoverflow.com/questions/27092833/unicodeencodeerror-charmap-codec-cant-encode-characters

The fix is to explicitly add utf-8 as the encoding when opening the output file. I've verified this in my local testing. Will send a PR

Discussion:

Solution patch:
diff --git a/README.md b/README.md
index 31d1bb1..332c663 100644
--- a/README.md
+++ b/README.md
@@ -52,7 +52,7 @@ This will output the contents of every file, with each file preceded by its rela
   files-to-prompt path/to/directory --ignore "*.log" --ignore "temp*"
   ```
 
-- `c/--cxml`: Output in Claude XML format.
+- `-c/--cxml`: Output in Claude XML format.
 
   ```bash
   files-to-prompt path/to/directory --cxml
@@ -64,6 +64,22 @@ This will output the contents of every file, with each file preceded by its rela
   files-to-prompt path/to/directory -o output.txt
   ```
 
+- `-n/--line-numbers`: Include line numbers in the output.
+
+  ```bash
+  files-to-prompt path/to/directory -n
+  ```
+  Example output:
+  ```
+  files_to_prompt/cli.py
+  ---
+    1  import os
+    2  from fnmatch import fnmatch
+    3
+    4  import click
+    ...
+  ```
+
 ### Example
 
 Suppose you have a directory structure like this:
diff --git a/files_to_prompt/cli.py b/files_to_prompt/cli.py
index 4fb358b..57a1bbf 100644
--- a/files_to_prompt/cli.py
+++ b/files_to_prompt/cli.py
@@ -25,26 +25,39 @@ def read_gitignore(path):
     return []
 
 
-def print_path(writer, path, content, xml):
+def add_line_numbers(content):
+    lines = content.splitlines()
+
+    padding = len(str(len(lines)))
+
+    numbered_lines = [f"{i+1:{padding}}  {line}" for i, line in enumerate(lines)]
+    return "\n".join(numbered_lines)
+
+
+def print_path(writer, path, content, xml, line_numbers):
     if xml:
-        print_as_xml(writer, path, content)
+        print_as_xml(writer, path, content, line_numbers)
     else:
-        print_default(writer, path, content)
+        print_default(writer, path, content, line_numbers)
 
 
-def print_default(writer, path, content):
+def print_default(writer, path, content, line_numbers):
     writer(path)
     writer("---")
+    if line_numbers:
+        content = add_line_numbers(content)
     writer(content)
     writer("")
     writer("---")
 
 
-def print_as_xml(writer, path, content):
+def print_as_xml(writer, path, content, line_numbers):
     global global_index
     writer(f'<document index="{global_index}">')
     writer(f"<source>{path}</source>")
     writer("<document_content>")
+    if line_numbers:
+        content = add_line_numbers(content)
     writer(content)
     writer("</document_content>")
     writer("</document>")
@@ -60,11 +73,12 @@ def process_path(
     ignore_patterns,
     writer,
     claude_xml,
+    line_numbers=False,
 ):
     if os.path.isfile(path):
         try:
             with open(path, "r") as f:
-                print_path(writer, path, f.read(), claude_xml)
+                print_path(writer, path, f.read(), claude_xml, line_numbers)
         except UnicodeDecodeError:
             warning_message = f"Warning: Skipping file {path} due to UnicodeDecodeError"
             click.echo(click.style(warning_message, fg="red"), err=True)
@@ -101,7 +115,9 @@ def process_path(
                 file_path = os.path.join(root, file)
                 try:
                     with open(file_path, "r") as f:
-                        print_path(writer, file_path, f.read(), claude_xml)
+                        print_path(
+                            writer, file_path, f.read(), claude_xml, line_numbers
+                        )
                 except UnicodeDecodeError:
                     warning_message = (
                         f"Warning: Skipping file {file_path} due to UnicodeDecodeError"
@@ -143,6 +159,13 @@ def process_path(
     is_flag=True,
     help="Output in XML-ish format suitable for Claude's long context window.",
 )
+@click.option(
+    "line_numbers",
+    "-n",
+    "--line-numbers",
+    is_flag=True,
+    help="Add line numbers to the output",
+)
 @click.version_option()
 def cli(
     paths,
@@ -152,32 +175,33 @@ def cli(
     ignore_patterns,
     output_file,
     claude_xml,
+    line_numbers,
 ):
     """
     Takes one or more paths to files or directories and outputs every file,
     recursively, each one preceded with its filename like this:
 
-    path/to/file.py
-    ----
-    Contents of file.py goes here
-
-    ---
-    path/to/file2.py
-    ---
-    ...
+    \b
+        path/to/file.py
+        ----
+        Contents of file.py goes here
+        ---
+        path/to/file2.py
+        ---
+        ...
 
     If the `--cxml` flag is provided, the output will be structured as follows:
 
-    <documents>
-    <document path="path/to/file1.txt">
-    Contents of file1.txt
-    </document>
-
-    <document path="path/to/file2.txt">
-    Contents of file2.txt
-    </document>
-    ...
-    </documents>
+    \b
+        <documents>
+        <document path="path/to/file1.txt">
+        Contents of file1.txt
+        </document>
+        <document path="path/to/file2.txt">
+        Contents of file2.txt
+        </document>
+        ...
+        </documents>
     """
     # Reset global_index for pytest
     global global_index
@@ -186,7 +210,7 @@ def cli(
     writer = click.echo
     fp = None
     if output_file:
-        fp = open(output_file, "w")
+        fp = open(output_file, "w", encoding='utf-8')
         writer = lambda s: print(s, file=fp)
     for path in paths:
         if not os.path.exists(path):
@@ -204,6 +228,7 @@ def cli(
             ignore_patterns,
             writer,
             claude_xml,
+            line_numbers,
         )
     if claude_xml:
         writer("</documents>")


Test patch:
diff --git a/tests/test_files_to_prompt.py b/tests/test_files_to_prompt.py
index 207a515..24d7234 100644
--- a/tests/test_files_to_prompt.py
+++ b/tests/test_files_to_prompt.py
@@ -279,3 +279,31 @@ def test_output_option(tmpdir, arg):
 ---
 """
         assert expected.strip() == actual.strip()
+
+
+def test_line_numbers(tmpdir):
+    runner = CliRunner()
+    with tmpdir.as_cwd():
+        os.makedirs("test_dir")
+        test_content = "First line\nSecond line\nThird line\nFourth line\n"
+        with open("test_dir/multiline.txt", "w") as f:
+            f.write(test_content)
+
+        result = runner.invoke(cli, ["test_dir"])
+        assert result.exit_code == 0
+        assert "1  First line" not in result.output
+        assert test_content in result.output
+
+        result = runner.invoke(cli, ["test_dir", "-n"])
+        assert result.exit_code == 0
+        assert "1  First line" in result.output
+        assert "2  Second line" in result.output
+        assert "3  Third line" in result.output
+        assert "4  Fourth line" in result.output
+
+        result = runner.invoke(cli, ["test_dir", "--line-numbers"])
+        assert result.exit_code == 0
+        assert "1  First line" in result.output
+        assert "2  Second line" in result.output
+        assert "3  Third line" in result.output
+        assert "4  Fourth line" in result.output