The Nightly Build.
Expert Tools

files-to-prompt folds a slice of a repo into one prompt, file by file

Behind the single command sits a 334-line script that has not been touched in seventeen months, still doing exactly the job it shipped to do.

How much of the repo a prompt actually needs

A model only reasons about the files it has been shown. Too few and it improvises around the gap. Too many and the real question sits buried under files that have nothing to do with it, paying full token price for none of the answer. files-to-prompt exists for that one job: concatenate a chosen slice of a directory into a single block of text, meant to be piped straight into a model instead of copied file by file into a chat window.1 Its author still describes it the same way years after building it: something to "pipe several files at once into prompts to LLMs such as Claude and GPT-4."1

The command that does that job is a 334-line Python script.2 Reading what it actually does, rather than what the README promises it does, is the only way to know which parts of the promise hold up and which ones don't.

A directory walk that drops three PNGs

Point it at a real subtree and the shape of the problem gets concrete. This paper's own engine/assets/ holds two stylesheets, one script, and three icon PNGs, the kind of directory a cat * or a quick find pipeline would dump wholesale into a prompt, icons and all.

engine/assets/ (default mode)CONSOLE
$ files-to-prompt engine/assets
Warning: Skipping file engine/assets/apple-touch-icon.png due to UnicodeDecodeError
Warning: Skipping file engine/assets/favicon-32.png due to UnicodeDecodeError
Warning: Skipping file engine/assets/favicon-64.png due to UnicodeDecodeError
engine/assets/nb.css
---
/* The Nightly Build — site chrome + shared article chrome. ...
---
Fig. 1 · A real run against this repository's own asset directory, files-to-prompt 0.6.3

Three warnings, one per icon, and none of the PNGs reach the prompt. What's left is 8,217 bytes: exactly the two stylesheets and the one script, with the combined roughly 172 KB of icon data excluded for the price of three lines on stderr.3

That exclusion is not a binary sniff, whatever the usual shorthand for it implies. process_path opens every file as text and skips it only when decoding fails.2 A PNG fails reliably. So would a UTF-16 source file, or any text file a filesystem happens to hand back with a stray byte in it. The tool doesn't detect binaries. It detects decode failures and treats every one of them the same way.

Every file gets a tag the model can cite back

Plain concatenation already beats hand-copying files into a chat window. The reason to reach for this tool specifically, rather than a bare cat, is --cxml. It wraps each file in a tag carrying its own path and an index number.2 A model reading the prompt can then name the exact file a fact came from, instead of guessing which pasted block it was looking at.

engine/ci_helpers.py engine/check.py (--cxml)CONSOLE
$ files-to-prompt engine/ci_helpers.py engine/check.py --cxml
<documents>
<document index="1">
<source>engine/ci_helpers.py</source>
<document_content>
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["pyyaml"]
# ///
"""Answer PR-shape and press-configuration questions for the CI workflows.
...
</document_content>
</document>
</documents>
Fig. 2 · Real --cxml output, two files, structure confirmed against the README and the source.4

Index 1 is ci_helpers.py, index 2 is check.py, both closed off by the same </document_content>/</document> pair the README documents, 7,576 bytes for the two files together.5 Ask a model a question against a prompt built this way and it can answer by pointing at <source>engine/check.py</source> instead of "the second file."

This is the step files-to-prompt actually occupies in a workflow: not a chat-window substitute, the assembly stage that runs once, right before the model call, replacing the manual work of finding the right files and pasting them in by hand. Its own author still runs it that way, piping straight into llm: files-to-prompt . -c | llm -m gemini-2.0-pro-exp-02-05 -s 'How does this work?...'. Scoped to two files it reads files-to-prompt database.py utils/__init__.py -c | llm -m o3-mini -o reasoning_effort high -s '...'.6 He described the tool as something he uses "on an almost daily basis" and finds "fantastic for quickly answering questions about code" when he wrote that up.6

Where the matching quietly gives up

-e is a suffix filter, not an extension check, and the difference lets in exactly the kind of file the flag is supposed to keep out.2 Running files-to-prompt . -e py against a scratch directory holding real.py, copy (no extension at all), and notpy.txt returned both real.py and copy. copy ends in the two characters "p" and "y", and the filter compiles to str.endswith(("py",)).2 notpy.txt was correctly excluded, since its name doesn't end that way. The filter reads the tail of a filename, not the extension a person would say the file has.7 An independent user hit the identical case against real C sources. -e c -e h matched Makefile.inc and build.sh too. "This actually matches any file that ends in c or h, rather than just looking at the extension," they wrote in July 2025. The workaround, passing the dot yourself with -e .c -e .h, isn't documented anywhere in the README. The issue has no maintainer response a year later.8

.gitignore honoring has the same shape: it works for the ordinary case and fails for a common one. A .gitignore naming __pycache__/ and *.pyc excludes a compiled artifact exactly as documented, and --ignore-gitignore brings it straight back if asked.9 But the matcher isn't a real gitignore implementation. should_ignore runs fnmatch against a file's bare basename, nothing more:

files_to_prompt/cli.pyPYTHON
def should_ignore(path, gitignore_rules):
    for rule in gitignore_rules:
        if fnmatch(os.path.basename(path), rule):
            return True
        if os.path.isdir(path) and fnmatch(os.path.basename(path) + "/", rule):
            return True
    return False
Fig. 3 · The entire gitignore-matching logic, verbatim.2

No leading-slash anchoring, no ** glob semantics, no negation. A rule like /src/staticfiles/, anchored to the repo root the way .gitignore syntax normally works, never matches. The pattern still carries its own slashes. fnmatch compares that literal string against the bare directory name staticfiles.2 A scratch repository with exactly that rule leaked src/staticfiles/bundle.txt straight into the prompt.10 A real Django project hit the same bug against its own real, anchored .gitignore. The reporter counted 19,929 lines of output that should have been excluded, including what they called "vast numbers of UnicodeDecodeErrors." The issue is still open a year and a half later.11 The project's own test suite covers nested, unanchored .gitignore inheritance and passes; it does not test the anchored case, which is exactly where the bug lives.12

Neither gap is exotic. A suffix match on filenames and a basename-only ignore matcher are both plausible things to ship in a 334-line script, and both are things a user only discovers by running the tool against their own repository, not by reading the README. There's no flag that catches either case for you. Only the habit of scoping tight and checking what actually came back.

Seventeen months quiet, two hundred installs a day

17 moSINCE LAST COMMIT
13OPEN ISSUES
~200/dayPYPI INSTALLS

Six releases landed in eleven months: 0.1 in March 2024 through 0.5 in mid-February 2025, then 0.6 five days later, on February 19, 2025.13 Nothing has shipped since: the same date marks the last commit of any kind to the project, bugfix, doc change, or dependency bump, roughly seventeen months earlier.14 Thirteen issues sit open, filed across that same window and since, none closed.15 The two failures demonstrated above are among them.

The tool's own author hasn't stopped using it. Two months after the last release, he was still running long-context models "via my files-to-prompt tool to summarize large codebases, explain how they work and even to debug gnarly bugs."16 A newer feature in his llm project, fragments, deduplicates repeated pastes across prompts by hashing their content, a complement to files-to-prompt's one-shot assembly, not a stated replacement for it.16 Real installs keep arriving too: 240, 226, and 194 a day across three consecutive days in late July 2026, seventeen months after the code that ships stopped changing.17

It also hasn't become anyone's default. Repomix, the project most often reached for now to "pack your codebase into AI-friendly formats," ships secret scanning and token counting on top of its own gitignore handling. It also carries roughly ten times the following: about 27,500 GitHub stars against files-to-prompt's 2,800.1819 files-to-prompt stayed the smaller, single-job tool instead of growing into the feature set Repomix now owns.

One more cost shows up only once someone else runs it against a bigger project. An independent adopter, testing the exact piped workflow against their own codebase, found their "project sizes and specification documents are likely to run into size limitations for local models (or for the clipboard at the very least)."20 files-to-prompt has no sense of how large a model's context window is. That judgment is entirely the operator's, every time, and it scales more comfortably toward hosted long-context models than local ones.

Verdict

Adopt it, for what it still does correctly: a source-verified way to hand a model exactly the files a question needs, tagged well enough that the model can cite its own answer back to a specific path. Don't extend it blind trust. Two real matching bugs sit unfixed and effectively unremarked for over a year. Its one maintainer has not committed in seventeen months. It has no sense of what belongs in a slice beyond the paths and flags a user types in, and its .gitignore handling only works unanchored. Treat it as a small, single-purpose utility whose entire 334 lines are worth reading before depending on them, not as a maintained platform with someone watching its issue queue.

Sources

  1. Simon Willison · "Building files-to-prompt entirely using Claude 3 Opus"
  2. simonw/files-to-prompt · files_to_prompt/cli.py
  3. PyPI · files-to-prompt 0.6, own installation and run
  4. simonw/files-to-prompt · README.md
  5. PyPI · files-to-prompt 0.6, own --cxml run
  6. Simon Willison · "files-to-prompt 0.5"
  7. PyPI · files-to-prompt 0.6, own -e suffix-filter run
  8. simonw/files-to-prompt · Issue #60, "Minor footgun: -e option is a suffix filter, not an exact extension check"
  9. PyPI · files-to-prompt 0.6, own gitignore-honoring run
  10. PyPI · files-to-prompt 0.6, own anchored-gitignore run
  11. simonw/files-to-prompt · Issue #46, "Ignored paths in gitignore are being included in the output"
  12. simonw/files-to-prompt · tests/test_files_to_prompt.py
  13. PyPI JSON API · files-to-prompt release history
  14. simonw/files-to-prompt · commit history, branch main
  15. simonw/files-to-prompt · open issues
  16. Simon Willison · "Long context support in LLM 0.24 using fragments and template plugins"
  17. PyPI Stats API · files-to-prompt download counts
  18. simonw/files-to-prompt · repository landing page
  19. yamadashy/repomix · repository landing page
  20. Hacker News · item 42562983