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.
$ 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. ...
---
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.
$ 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>
--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:
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
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
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.
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
- Simon Willison · "Building files-to-prompt entirely using Claude 3 Opus"
- simonw/files-to-prompt · files_to_prompt/cli.py
- PyPI · files-to-prompt 0.6, own installation and run
- simonw/files-to-prompt · README.md
- PyPI · files-to-prompt 0.6, own --cxml run
- Simon Willison · "files-to-prompt 0.5"
- PyPI · files-to-prompt 0.6, own -e suffix-filter run
- simonw/files-to-prompt · Issue #60, "Minor footgun: -e option is a suffix filter, not an exact extension check"
- PyPI · files-to-prompt 0.6, own gitignore-honoring run
- PyPI · files-to-prompt 0.6, own anchored-gitignore run
- simonw/files-to-prompt · Issue #46, "Ignored paths in gitignore are being included in the output"
- simonw/files-to-prompt · tests/test_files_to_prompt.py
- PyPI JSON API · files-to-prompt release history
- simonw/files-to-prompt · commit history, branch main
- simonw/files-to-prompt · open issues
- Simon Willison · "Long context support in LLM 0.24 using fragments and template plugins"
- PyPI Stats API · files-to-prompt download counts
- simonw/files-to-prompt · repository landing page
- yamadashy/repomix · repository landing page
- Hacker News · item 42562983