← Files

ctf — Collect To File#

Recursively collects source files into a single Markdown, JSON, JSONL or text
document, preserving paths relative to the source root. Built for assembling
LLM context, code-review bundles and auditable source snapshots.


Contents#

  1. Why

  2. Install

  3. Quick start

  4. Options reference

  5. Output formats

  6. Controlling what gets collected

  7. Fitting a token budget

  8. Self-update

  9. Exit codes

  10. Windows

  11. Configuration file

  12. Behaviour worth knowing

  13. Development

  14. Performance

  15. License


1. Why#

Three situations come up constantly:

ctf is a single self-contained script with no dependencies beyond a POSIX
userland. It does not shell out to eval, it does not execute anything it
reads, and it never writes outside the file you asked for.


2. Install#

git clone https://github.com/paulmann/ctf-collect-to-file.git
cd ctf-collect-to-file

# user-level install into ~/.local/bin
make install

# or system-wide
sudo make install PREFIX=/usr/local

# or without make
./install.sh --prefix "$HOME/.local"

Verify:

ctf --version      # ctf v4.1.0
ctf --print-config .

On Windows, use the PowerShell installer, which also puts the bare name ctf
on the PATH — see §10.1:

git clone https://github.com/paulmann/ctf-collect-to-file.git
cd ctf-collect-to-file
pwsh -NoLogo -NoProfile -NonInteractive -File .\install-ctf.ps1

Requirements: bash 4.2+ (CentOS/RHEL 7 baseline), plus awk, find,
sort, sed, head, tail, od, cat, wc, mktemp, stat.
Optional and auto-detected: file (faster binary check), git (--git),
sha256sum/shasum (--dedup, --metadata), curl/wget/python3/perl
(--update). Run ctf --print-config . to see what was detected.


3. Quick start#

# every PHP file under ./src into result.md
ctf php ./src result.md

# several extensions, modern option form
ctf -e php,js,ts -o bundle.md ./src

# everything the repository tracks or would track (honours .gitignore)
ctf --git all -o context.md .

# exactly 120k estimated tokens of Python, to the clipboard
ctf -e py --token-budget 120000 -o - . | pbcopy        # macOS
ctf -e py --token-budget 120000 -o - . | xclip         # Linux

# what would be collected, and how big would it be?
ctf --stats ./src
ctf --dry-run -e py ./src | head

4. Options reference#

ctf [OPTIONS] [EXTENSION] [SOURCE_DIR] [OUTPUT_FILE]

The three positional arguments are kept for compatibility with v1–v3 and are
equivalent to --ext, the source directory and --output. See
§12 for how positional arguments are resolved
when options are also present.

Collection#

Option Meaning
-e, --ext LIST Comma-separated extensions, e.g. php,js,sh. Repeatable. A leading dot is optional, matching is case-insensitive. Omit to collect every text file.
-E, --exclude GLOB Exclude matching paths. Repeatable. * also crosses /; the bare file name is matched too.
-I, --include GLOB Keep only matching paths. Repeatable.
--exclude-dir NAME Exclude a directory name at any depth. Repeatable, comma-separated list accepted.
--no-default-excludes Do not apply the built-in VCS/dependency/build excludes.
--list-default-excludes Print the built-in exclude lists and exit.
-d, --max-depth N Maximum depth below SOURCE_DIR (0 = unlimited).
-L, --follow Follow symlinks. Without it, symlinked files are counted as skipped and directory symlinks are not descended.
--max-size SIZE Skip files larger than SIZE (bytes or K/M/G/T suffix).
--min-size SIZE Skip files smaller than SIZE.
--files-from FILE Take the file list from FILE (- = stdin), newline- or NUL-separated, relative to SOURCE_DIR.
--git MODE Enumerate with git instead of find: tracked = git ls-files --cached; all = tracked + untracked, honouring .gitignore.
--binary MODE Binary detection: auto (default), never, always.

Output#

Option Meaning
-o, --output FILE Destination. - writes to stdout. Default: all-<ext>-files.md or All-Project-Files.md.
-F, --format FMT md (default), json, jsonl, txt.
--heading-level N Markdown heading level for file paths (1–6, default 3).
--path-style STYLE rel (default) or abs.
--sort KEY path (default), name, size, mtime. size/mtime sort descending.
--title TEXT Document title.
--no-header Omit the metadata header.
--no-summary Omit the trailing summary.
--no-timestamp Omit the generation timestamp — makes the output byte-reproducible.
--toc Add a table of contents with GitHub-compatible anchors.
--metadata Add a per-file HTML comment: bytes, lines, sha256/12.
--line-numbers Prefix every content line with its number.
--lang-style STYLE fenced (default), indent4, none.
--strip-bom Remove a leading UTF-8 BOM from each file.
--truncate-lines N Keep at most N content lines per file (0 = all).
--token-budget N Estimated token budget for the whole document.
--budget-action A What to do with a file that does not fit: truncate (default) or drop.
--dedup Emit byte-identical files once (requires sha256sum or shasum).

Behaviour#

Option Meaning
-n, --dry-run Print the selection as TSV (path, lang, bytes, lines, sha256/12); write nothing.
--stats Print key=value statistics and exit.
--strict Exit 4 when nothing was collected.
-c, --config FILE Read VAR=VALUE defaults from FILE.
--print-config Show the effective configuration and detected tools, then exit.
-q, --quiet Errors only on stderr.
-v, --verbose Debug output on stderr, including every skip and its reason.
--color WHEN auto (default), always, never. Colours go to stderr only.
-h, --help Help.
-V, --version Version.

Self-update#

Option Meaning
--check-update Query the remote and report; exit 0 when current, 3 when newer or unreachable.
--update Download and install the newer version over this script.
--update-channel CH main (default), latest (newest release tag) or any branch/tag.
--update-force Reinstall even when the version is identical.
--update-timeout S Per-request timeout in seconds (default 15).

Environment#

Variable Meaning
CTF_CONFIG Default configuration file (overridden by --config).
NO_COLOR Any value disables colours.
SOURCE_DATE_EPOCH Unix timestamp used instead of “now” — reproducible builds.
CTF_UPDATE_CHANNEL Default update channel.
CTF_UPDATE_TIMEOUT Default per-request timeout, seconds.
CTF_UPDATE_BASE_URL Download root override: internal mirror, air-gapped artifact store, or a test server.
CTF_BINARY_SCAN_BYTES Bytes inspected by the fallback binary scan (default 8192).
CTF_BINARY_CTRL_PCT Control-byte percentage above which a file is binary (default 5).

5. Output formats#

Markdown (default)#

# Project Source Code Aggregate

| Field | Value |
|:------|:------|
| Generated (UTC) | 2026-10-08T12:00:00Z |
| Tool | ctf.sh v4.0.0 |
| Source | /repo |
| Extensions | php |
| Candidates | 42 |
| Collected | 37 |

---

### `src/Api/Client.php`

```php
<?php
...

Summary#

Metric Value
Candidates 42
Collected 37
Skipped 5
Source bytes 184320 (180.00 KiB)
Estimated tokens 46080

Skip reasons

Reason Count
binary 2
excluded-dir 3

The skip-reason table is the part that saves time: when a file you expected is
missing, the document itself tells you why.

### JSON / JSONL

`--format json` produces one object; `--format jsonl` produces one object per
line (streamable, `jq`-friendly):

```json
{"path":"src/Api/Client.php","lang":"php","bytes":4096,"lines":120,"sha256":"a1b2…","content":"<?php\n…"}

content is JSON-escaped; control characters other than \t, \n and \r
are removed, since JSON cannot carry them unescaped and source text never
contains them.

Plain text#

--format txt emits ===== path (lang, size) ===== separators and no Markdown
at all — convenient for diff, grep and diff-based review tools.


6. Controlling what gets collected#

Default excludes. VCS metadata (.git, .svn, .hg), dependency trees
(node_modules, vendor, .venv, __pycache__), build output (build,
dist, out, target, obj, release), caches, IDE directories and
generated files (*.min.js, *.map, lock files, *.pyc, shared objects,
executables). Excluded directories are pruned by find before descent, so
large node_modules trees cost nothing. List them with
--list-default-excludes, disable with --no-default-excludes.

The source root itself is never excluded. A tree rooted at ./dist,
./build or ./out is scanned normally — only directories below the root
are matched against the exclude list.

Patterns. Globs use bash pattern syntax, where * also crosses /:

ctf -E 'tests/*' -E '*.md' -E 'docs/**' -o ctx.md .
ctf -I 'src/**' -o src-only.md .
ctf --exclude-dir legacy,tmp -o clean.md .

Git-aware selection. --git all is usually what you want inside a
repository: it uses the index plus untracked-but-not-ignored files, so
.gitignore does the filtering for you and build output never appears.

Symlinks are skipped by default and reported as skip_symlink — never
silently. Pass -L to follow them.


7. Fitting a token budget#

The estimate is ceil(bytes / 4), which is conservative for source code.

ctf -e py --token-budget 120000 --stats .        # will it fit?
ctf -e py --token-budget 120000 -o ctx.md .      # default: truncate to fit
ctf -e py --token-budget 120000 --budget-action drop -o ctx.md .

Files are taken in --sort order until the budget is exhausted. With
--budget-action truncate (default) the file that does not fit whole is cut at
a line boundary and marked with an HTML comment; with drop it is skipped and
counted in budget_dropped. Both are reported in the summary.

Combine with --sort size to prioritise the largest files, or with
--max-size 64K to keep one generated monster from eating the window.


8. Self-update#

ctf --check-update          # local=4.1.0 remote=4.2.0 channel=main status=newer
ctf --update                # installs 4.2.0, keeps ctf.sh.bak-<timestamp>
ctf --update --update-channel latest

Safety properties, each covered by tests/test_update.sh:


9. Exit codes#

Code Meaning
0 Success.
1 Runtime error: I/O failure, permissions, unexpected state.
2 Usage error: unknown option, invalid value, unreadable source.
3 Update or network error (--check-update, --update).
4 Nothing collected and --strict was given.

--check-update uses 3 for “a newer version exists”, so it can be used
directly as a CI gate:

ctf --check-update && echo "up to date" || echo "update available"

10. Windows#

ctf.ps1 is the Windows implementation; ctf.bat is the upstream launcher.
Both are CRLF in the repository — enforced by .gitattributes, because LF line
endings break labels and goto in cmd.exe.

ctf.bat php C:\projects\src C:\out\bundle.md
ctf.bat --check-update
.\ctf.ps1 -Ext php,js -Source .\src -Output bundle.md

The PowerShell port implements the collection, filtering, binary detection,
fence selection and Markdown/JSON output. Options that depend on POSIX
semantics (symlink modes, --files-from - from stdin) behave as documented in
ctf.ps1 -Help.

10.1 Install into PATH as ctf#

git clone https://github.com/paulmann/ctf-collect-to-file.git
cd ctf-collect-to-file
pwsh -NoLogo -NoProfile -NonInteractive -File .\install-ctf.ps1

Requirements: PowerShell 7.6+ (pwsh) for the installer. The engine itself
also runs on Windows PowerShell 5.1. The installer copies the engine byte for
byte, writes the launcher below, and appends one directory to the user PATH;
it never edits the engine and never touches the machine-wide PATH.

Installed layout:

<InstallRoot>\bin\ctf.cmd    launcher; only this directory goes on PATH
<InstallRoot>\lib\ctf.ps1    the engine, byte-identical to the repository
<InstallRoot>\lib\ctf.bat    upstream launcher, kept for parity
<InstallRoot>\install-ctf.ps1

After installation, ctf resolves to the same thing from both shells:

pwsh -NoProfile -Command "(Get-Command ctf).Source"   # ...\bin\ctf.cmd
cmd  /c "where ctf"                                    # ...\bin\ctf.cmd

10.2 Why the engine is not on PATH#

Putting ctf.ps1 in a PATH directory looks simpler and breaks in two
measured ways. Both were reproduced on PowerShell 7.6.

PowerShell prefers a .ps1, and does not bind --long-option arguments to
script parameters.
A bare command name resolves to a .ps1 in preference to
a .cmd, and --name style arguments are not bound to parameters — the
literal string becomes a positional argument.

With the engine on PATH Expected Actual
ctf --version prints the version runs a collection: "--version" fills the positional Extension, and all---version-files.md appears in the current directory
ctf --ext php --output out.md .\src writes out.md A positional parameter cannot be found that accepts argument 'out.md'
ctf --toc adds a table of contents writes all---toc-files.md

cmd.exe prefers a .bat, and the upstream ctf.bat enables delayed
expansion
, which deletes ! characters from every argument. A path such as
B:\!1\src was silently truncated to B:, and the wrong directory was scanned
— with no error in the output at all.

The launcher written by the installer therefore sets
EnableExtensions DisableDelayedExpansion, calls the engine through
pwsh -File, and forwards both arguments and exit code. Paths containing !,
& or spaces survive intact; a path with ! is the regression this guards
against.

10.3 Option names differ between the ports#

Document layout and most options are the same, but not every name is. The
cause is a platform rule, not an oversight: PowerShell binds parameter names
case-insensitively, so bash option pairs that differ only in case — -e/-E
for --ext/--exclude, -v/-V for --verbose/--version — cannot both
have short forms. Verified by running the installed launcher.

Feature bash (ctf.sh) Windows (ctf.ps1)
version -V, --version --version; -V is --verbose
exclude -E, --exclude --exclude; -E is bound to --ext
verbose -v, --verbose -v, -Trace, --trace; --verbose is silently ignored
config file -c, --config not supported
effective configuration --print-config not supported
--files-from - (stdin) supported exits 2
--git tracked outside a repository supported exits 2

Short forms that work on Windows: -e (--ext), -o (--output), -F
(--format), -d (--max-depth), -I (--include), -n (--dry-run),
-q (--quiet), -v (--verbose), -h (--help), -L (--follow). Use
the long form for everything else. ctf.ps1 -Help is the authoritative option
list for the Windows port.

--verbose is a silent no-op on Windows and is worth calling out, because
it fails without a word. The engine's own flag is named Trace and aliased to
v; -Help documents it as -Trace — "Debug output (equivalent of the bash
--verbose)". The literal argument --verbose matches no parameter of the
script, but PowerShell binds it to its common -Verbose parameter, so the
call succeeds, prints nothing extra, and exits 0. Measured through the
installed launcher on a tree containing a binary file — the one code path that
emits a debug line:

Argument ctf debug lines emitted
-v 1
-Trace / --trace 1
--verbose 0
-Verbose 0

Use -v (or -Trace) when you need the skip reasons. Note that the port emits
debug output from a single call site — the deterministic binary-skip during
collection — so -v is far quieter than the bash --verbose, which reports
every skip with its reason. For a per-file inventory of what was selected and
skipped, use --dry-run (one TSV row per file), or read the skip-reasons table
at the end of the produced document.

Exit codes. A PowerShell parameter-binding failure exits 1, not 2; code
2 comes from the engine's own usage checks. --check-update exits 3 when a
newer version exists, so it still works as a CI gate.

10.4 Line endings and linting#

.bat and .ps1 are CRLF; .sh, Makefile and .gitattributes are LF. CI
enforces this, together with a parser check of ctf.ps1, so normalising a
Windows file to LF fails the build. ctf.ps1 deliberately has no #Requires
directives, so that the port stays loadable on Windows PowerShell 5.1 — do not
add them without a matching decision.


11. Configuration file#

# ~/.ctfrc — plain VAR=VALUE, no code
CTF_FORMAT=md
CTF_SORT=path
CTF_TOKEN_BUDGET=120000
CTF_DEFAULT_EXCLUDES=1
CTF_TITLE="Project sources"
ctf -c ~/.ctfrc -e py .
CTF_CONFIG=~/.ctfrc ctf -e py .
ctf --print-config .        # what is actually in effect, and why

The file is treated strictly as data:


12. Behaviour worth knowing#

Positional arguments fill the slots EXTENSION, SOURCE_DIR,
OUTPUT_FILE in that order, but only slots that no option has already filled,
and an argument naming an existing directory is always taken as SOURCE_DIR:

Invocation Extension Source Output
ctf php ./src out.md php ./src out.md
ctf -e php ./src php ./src default
ctf ./src — ./src default
ctf -e php -o out.md ./src php ./src out.md

Binary detection uses two signals, both false-positive-free for real text:
a NUL byte in the first 8 KiB, or more than 5 % C0 control bytes (excluding
\t \n \v \f \r). High bytes are not evidence of binary — otherwise every
non-English source file would be dropped. When file(1) is present its verdict
is used first. Known limitation: a file under ~1 KiB of high-entropy data with
no NUL and very few control bytes can pass as text; use --ext or
--binary always when that matters.

Fences are computed per file. A fenced block is closed only by a line
consisting of nothing but fence characters (optionally indented up to 3
spaces), so ctf finds the longest such run in the file and emits a fence one
character longer — choosing backticks or tildes, whichever is shorter. Content
is therefore never able to break out of its block.

A file without a trailing newline still gets its closing fence on a line of
its own.

File names may contain any byte except / and NUL, and ctf handles
spaces, quotes, backticks, $, ;, tabs and newlines — verified by
tests/test_safety.sh, which also asserts that no payload inside a file name
is ever executed. Names containing CR/LF/TAB are flattened for display only
(headings, TOC, TSV); content is copied byte-for-byte.

Reproducibility. With --no-timestamp (or SOURCE_DATE_EPOCH), identical
input yields byte-identical output, so a snapshot can be committed and diffed.

The output file is never collected into itself, even when it lives inside
the scanned tree; the skip is reported as skip_output_file.

Atomic writes. The document is assembled in temporary files next to the
destination and moved into place; a symlinked destination is written through,
so the link is not replaced by a regular file. Temporary files are removed by
an EXIT trap.


13. Development#

make test          # full suite: 8 files, 243 assertions, no network, no root
make lint          # shellcheck --severity=style on ctf.sh and tests
make check         # lint + test
make install       # install ctf.sh, ctf.ps1, ctf.bat
make uninstall

Or directly:

tests/run_tests.sh                 # everything
tests/run_tests.sh --filter binary # one file
tests/run_tests.sh --verbose       # print every passing assertion
tests/run_tests.sh --keep-tmp      # keep the scratch directory
CTF=/path/to/other/ctf.sh tests/run_tests.sh   # test a different copy

Layout:

ctf.sh             the tool (bash 4.2+, 2222 lines, shellcheck-clean at style level)
ctf.ps1            Windows implementation (PowerShell 5.1+, 1139 lines)
ctf.bat            Windows launcher (68 lines, CRLF)
VERSION            single source of truth for the version, read by --update
tests/             8 test files, 243 assertions; sources ctf.sh, no framework
  run_tests.sh     runner (--filter, --verbose, --keep-tmp)
  lib.sh           assertions, fixtures, ctf_run/ctf_stats helpers
  test_cli.sh      options, positional forms, exit codes
  test_collect.sh  extensions, excludes, symlinks, depth, size, git, --files-from
  test_binary.sh   binary detection: determinism and false positives
  test_output.sh   document structure, fences, formats, TOC, budget, BOM
  test_safety.sh   atomic writes, hostile file names, config file, temp hygiene
  test_update.sh   self-update end-to-end against a local HTTP server
  test_parity.sh   ctf.sh vs ctf.ps1: byte-identical output and CLI parity
  test_meta.sh     versions, shellcheck, bash 4.2 portability, docs consistency
docs/ANALYSIS.md   audit of v3.1.0: 20 defects with reproduction commands
docs/DECISIONS.md  20 design decisions: context, choice, cost
install.sh         POSIX installer (no make required)
Makefile           install / uninstall / test / lint / check / dist
install-ctf.ps1    Windows installer: copies the engine, writes the launcher,
                   adds its directory to the user PATH (see §10.1)
SKILL.md           agent-facing usage and installation guide
.github/workflows/ci.yml

CI runs shellcheck, bash -n, the suite (Ubuntu and macOS), version
consistency across VERSION/ctf.sh/README/CHANGELOG, a PowerShell parse
check of ctf.ps1, and line-ending policy (.bat/.ps1 CRLF, .sh LF).
When pwsh is present — it is on ubuntu-latest — test_parity.sh also
compares the two implementations byte-for-byte.

Linting policy (see docs/DECISIONS.md, DR-18): ctf.sh and install.sh
must be clean at --severity=style; tests must be clean at
--severity=warning; deliberate suppressions carry an explanatory directive
instead of a global .shellcheckrc.


14. Performance#

Metadata reads are batched — sizes via stat --printf, binary classification
via grep -IlZ, and lines/fence-runs/byte-sums via one awk pass per ~200
files. The only per-file process left is the cat that copies content, and
hot-path helpers return through globals instead of command substitution, so no
subshell is forked per file.

Measured in a container, 2 CPU:

Tree v3.1.0 v4.0.0
2 000 small JS files 19.0 s 9.0 s
600 files / 13 MiB — 2.7 s

Measured in a container with 2 CPU. Batched output is byte-identical to the
unbatched implementation, which the determinism tests assert.

Batched output is byte-identical to the unbatched implementation (asserted by
the determinism tests).


15. License#

PolyForm Noncommercial 1.0.0 — see LICENSE.

Author: Mikhail Deynekin — Mikhail@Deynekin.com — https://deynekin.com