Pack a repository (or a subset) into a single, token-budgeted, LLM-ready context bundle. Deterministic, offline-first, and built in C.
- Minimal & fast. Single static binary with zero runtime network dependencies (vendored cJSON and getopt).
- Token-budget aware.
--budgetuses rendered-token estimates so the emitted bundle actually fits the cap. - Multi-format. Emit as Markdown, XML, JSON, or plain text (
--format md|xml|json|txt). - Git-native.
diffanddiff --stagedpack only changed files from the work tree. - Smart selection.
--include/--excludeglobs,--query(path-weighted keyword ranking),--sort name|mtime|size. - Deterministic. Same inputs → identical bytes (byte-wise path ordering, no locale).
- Cross-platform. Builds cleanly on Linux, macOS, and Windows (gcc/clang/MSVC).
Download a prebuilt binary from releases, or build from source:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build --parallel
# binary at build/cctxpack (or build/Release/cctxpack.exe on Windows)Linux, macOS, Windows install
Linux (static, no runtime deps):
VERSION=0.2.1
curl -fsSLO "https://github.com/Nmd-sketch/cctxpack/releases/download/v${VERSION}/cctxpack-linux-x86_64.tar.gz"
sha256sum -c SHA256SUMS 2>/dev/null || sha256sum cctxpack-linux-x86_64.tar.gz
tar xzf cctxpack-linux-x86_64.tar.gz
sudo install -m 0755 pkg/cctxpack /usr/local/bin/cctxpackmacOS (universal — Apple Silicon and Intel):
VERSION=0.2.1
curl -fsSLO "https://github.com/Nmd-sketch/cctxpack/releases/download/v${VERSION}/cctxpack-macos-universal.tar.gz"
tar xzf cctxpack-macos-universal.tar.gz
sudo install -m 0755 pkg/cctxpack /usr/local/bin/cctxpackWindows (PowerShell):
$VERSION = "0.2.1"
Invoke-WebRequest "https://github.com/Nmd-sketch/cctxpack/releases/download/v$VERSION/cctxpack-windows-x64.zip" -OutFile cctxpack.zip
Expand-Archive cctxpack.zip -DestinationPath .
.\pkg\cctxpack.exe --versionA second Windows build is available as cctxpack-windows-x64-mingw.tar.gz
(MinGW, also static).
Every release ships a SHA256SUMS file. Verify with sha256sum -c SHA256SUMS
(Linux/macOS) or Get-FileHash (Windows).
cctxpack . -o context.mdcctxpack . --budget 8000 --reportcctxpack diff -o review.md # staged + unstaged + untracked
cctxpack diff --staged -o staged.md # index only
cctxpack diff --patch -o patch.md # the unified diff itselfdiff reports everything not yet committed — staged, unstaged and
untracked files. Deleted paths are skipped, since there is nothing to pack.
--patch emits the unified diff rather than whole files. That matters when the
model has no earlier copy of the file to compare against (a fresh session, or
after context compaction): full files plus a remembered old version shows what
changed, but full files alone do not. It is also dramatically cheaper — the
patch is a few hundred tokens where the files were tens of thousands.
cctxpack diff --patch --format json # machine-readable, still valid JSONUntracked files have no diff representation, so they appear only in plain
cctxpack diff, never in --patch.
Four commands. pack is the default, so cctxpack . and cctxpack pack . are
the same thing.
| Command | What it does |
|---|---|
cctxpack [pack] [paths...] |
Emit the context bundle (default command) |
cctxpack count [paths...] |
Per-file bytes / lines / tokens table, no content |
cctxpack tree [path] |
Render the directory tree only |
cctxpack diff [--staged] |
Pack files changed since HEAD |
count is the cheapest way to answer "how big is this, and what would I be
sending?" without generating a bundle. tree is the cheapest way to answer
"what is even in here?".
Every flag the binary accepts. Value-taking flags show their argument;
count and tree ignore the bundling flags (--budget, -o, --clipboard).
| Flag | Argument | Applies to | Meaning |
|---|---|---|---|
--budget N |
integer | pack, diff | Token budget (est.). Auto-enables --report. Exit 3 if nothing fits |
--format F |
md|xml|json|txt |
all | Output format (default md) |
--include G |
glob, repeatable | all | Include only matching paths |
--exclude G |
glob, repeatable | all | Drop matching paths |
--max-file-tokens N |
integer | pack, diff, count | Truncate a file above N est. tokens |
--sort S |
name|mtime|size |
all | Ordering (default name) |
--query TEXT |
string | pack, diff | Re-rank candidates by keyword overlap |
--tree / --no-tree |
— | pack, diff | Include/omit the directory tree (default on, off for diff) |
--report |
— | pack, diff | Print the budget report on stderr |
--outline |
— | pack, diff, count | Send declarations instead of whole files |
--redact |
— | pack, diff | Replace credential-shaped text (opt-in) |
--config |
— | all | Print the resolved configuration and exit |
--find PATTERN |
string | all | Fixed-string search: print path:line: text for each match and exit. Exit 4 if no match |
-o FILE |
path | pack, diff | Write the bundle to FILE (default stdout) |
--clipboard |
— | pack, diff | Copy to the Windows clipboard |
--staged |
— | diff | Staged changes only |
--patch |
— | diff | Emit the unified diff instead of whole files |
-h, --help |
— | all | Usage |
-V, --version |
— | all | Version |
--budget turns the report on by itself; --report asks for it explicitly.
It goes to stderr, so it never corrupts a bundle piped to stdout.
cctxpack src --outline --budget 8000 --report -o ctx.mdcctxpack: included 12 files (6 at outline), excluded 17
cctxpack: est. tokens 7980/8000 (budget)
included counts what made it in; the parenthetical says how many of those
arrived as signatures rather than whole files. If nothing fits, cctxpack says
why and exits 3 rather than emitting an empty bundle:
cctxpack: nothing fits the budget (reserve 216, budget 5)
The reserve is the header, tree and closing markup — the parts of the bundle that are not file content. A budget smaller than the reserve cannot be honoured by any selection, which is why that case is reported rather than silently truncated.
.cctxpack.json, command-line flags and defaults all set the same values, so
"why is my budget 8000 when I passed 6000?" needs an answer that is not a guess:
cctxpack --config
cctxpack --config --budget 6000 # the flag wins, and says sofile=/home/me/proj/.cctxpack.json
budget=6000
max_file_tokens=4000
format=md
sort=name
tree=1
include=
exclude=*.log
redact=0
outline=0
file= is empty when no config file was found. An empty include=/exclude=
means none were set; repeated globs appear as include+= lines.
A full reference ships in every release archive (docs/man1/cctxpack.1 in the
tarball/zip) and covers what this README does not: the complete flag semantics,
plus dedicated OUTLINE, TRUNCATION, REDACTION, CONFIGURATION,
ENVIRONMENT and EXAMPLES sections.
man ./docs/man1/cctxpack.1 # from a checkout
man cctxpack # after installing to a manpathThe three sections worth reading before sending a repo to a hosted model are REDACTION (what it does and, more importantly, what it will not catch), TRUNCATION (when a number describes less than it appears to) and OUTLINE (when the scanner refuses and sends whole text instead).
# .cctxpackignore — one glob per line, '#' comments, blank lines ignored
vendor/*
*.snap
docs/internal/**Layered on top of .gitignore, not a replacement: files stay tracked by git,
they just stop being packed. A missing file is not an error.
cctxpack . --query "auth jwt" --budget 6000Opt-in. Without --redact you get exactly the bytes on disk — fidelity is the
whole point of this tool, and quietly rewriting content would break it.
cctxpack . --redact -o safe.md
# cctxpack: redacted 2 credential(s)- AWS_KEY = "AKIAIOSFODNN7EXAMPLE"
- db_password = "s3cr3t-prod-value"
+ AWS_KEY = "[REDACTED:aws-access-key-id]"
+ db_password = "[REDACTED:password]"It replaces whole PEM private key blocks, vendor token shapes (AKIA…, ghp_,
github_pat_, AIza…, xox…, sk_live_, JWTs), and values assigned to
unambiguously secret names (password, secret, api_key, access_token,
client_secret, …) of at least 6 bytes.
Two properties worth knowing:
- The line count is preserved exactly. A multi-line key block collapses to a marker plus blank lines, so line numbers you were given still point where they did. Only the secret span is rewritten, so the statement around it still parses.
- The count is reported, in the manifest and on stderr, so an agent can tell a scrubbed file from an empty one.
What it deliberately does not catch. There is no entropy heuristic, and that
is the important limitation: length-and-alphabet scoring mislabels SHA digests,
base64 assets, lockfile integrity hashes and minified bundles far more often than
it catches a real key. Placeholders (your-…, ${VAR}, <…>, changeme) and
derived values (password_hash) are left alone, as are secrets that live only in
environment variables.
This is not a security guarantee. It reduces accidental disclosure of committed credentials; it does not replace a secret scanner, and it will miss credentials in shapes it does not know. See the man page's REDACTION section for the full list.
--outline sends each file's imports and signatures, with line numbers,
instead of its body — enough to know what a repository contains without paying
for how it is implemented.
cctxpack src --outline --format md### pack.c (27 decls, outline)
1: #include "pack.h"
3: #include "format.h"
4: #include "fit.h"
...
47: static int cmp_item(const void *a, const void *b)
63: static void report(const pack_opts *o, const pack_item *items, ...
...Combined with --budget, it becomes progressive disclosure — the most
relevant files arrive whole, the rest arrive as signatures, and everything stays
inside the budget:
cctxpack src --outline --budget 8000 --report -o ctx.mdcctxpack: included 9 files, excluded 20
cctxpack: excluded files.c (over budget)
cctxpack: excluded format.c (over budget)
...
cctxpack: est. tokens 7957/8000 (budget)
At the same budget, cctxpack src --budget 8000 fits 6 files, all whole.
With --outline it fits 9 — 5 whole and 4 as signatures (--format json
is how you tell which is which: an outlined file carries an outline array
instead of content). That is the actual trade, stated plainly: the same
budget covers more of the repository, and fewer of the files arrive complete.
On this repository's src/ that turns 47,120 tokens into 4,177 — 11.3×
smaller. Measured across corpora:
| Corpus | Files | Full text | Outline | Smaller by |
|---|---|---|---|---|
cctxpack/src/ (C) |
29 | 47,120 | 4,177 | 91% |
cctxpack/tests/ (C) |
20 | 40,760 | 3,733 | 91% |
cctxpack/vendor/ (C) |
14 | 53,637 | 5,241 | 90% |
sniff/ (C++) |
99 | 83,245 | 33,357 | 60% |
cctxpack/ (whole repo) |
77 | 185,466 | 48,110 | 74% |
.agency-agents/ (Markdown) |
336 | 1,222,617 | 1,216,300 | 1% |
The last row is the honest one, and it is why the table exists. Outline mode only helps code. Markdown is refused by design — cctxpack will not invent structure it cannot see — so a documentation-heavy corpus sees almost nothing, and the whole-repo figure (74%) is a blend of the 90%-saving code and the 1%-saving prose.
It refuses rather than guesses. Any file the scanner cannot account for — unbalanced braces, an extension outside the supported set, a substantial file yielding nothing recognisable — is sent whole, not outlined. A wrong outline is worse than none, because a consumer cannot tell them apart. On an unrelated C++ codebase, 16 of 99 files refused, and 15 of those were Markdown.
With --format json the outline is a structured array, not rendered text:
{"path": "fit.c", "tokens": 28, "outline": [
{"line": 8, "decl": "#include \"fit.h\""},
{"line": 10, "decl": "size_t pack_fit(const size_t *ests, size_t n, size_t remaining,"}
]}Note tokens is the outline's size (28 here, against 196 for the file's
full text) — the manifest and the per-file counts both describe the bundle, not
the files it was built from.
A consumer can select from an array; it cannot select from prose. And
cctxpack count --outline reports what would actually be sent, through the same
code path pack uses, so the two cannot silently disagree.
cctxpack . --format json -o bundle.json
cctxpack . --format xml -o bundle.xml
cctxpack . --format txt -o bundle.txtEvery bundle starts with a provenance manifest — commit SHA, file count, content bytes, estimated tokens, and a digest identifying the exact file set:
<!-- cctxpack-manifest commit=8125733d18ee files=9 bytes=4191 tokens=1242 digest=9d2ae02d631a65a0 -->That matters most for agents: the consumer can say which snapshot it received
and whether two bundles hold the same files. JSON gets a "manifest" object,
XML attributes on <context>, txt a manifest: line. There is deliberately
no timestamp — two runs over an unchanged tree are byte-identical.
cctxpack count .
cctxpack count . --format json
cctxpack tree . bytes lines tokens path note
14077780 79559 1180582 big.c read cap: 4194304 of 14077780 bytes
50000 1023 14143 small.c
14127780 80582 1194725 total some files truncated
Read bytes as "bytes on disk". lines and tokens describe what was
actually read, and those only describe the same content when nothing was
dropped. Files above 4 MiB are read up to that cap — a memory bound over
whatever path you pointed at, not a tuning knob — and note says so per row.
In JSON the same appears as truncated, truncation (read-cap or
token-cap) and, for a read cap only, bytes_packed. See the man page's
TRUNCATION section.
cctxpack . --clipboardcctxpack walks up from the cwd (stopping at the git work-tree root) and merges the nearest .cctxpack.json. CLI flags override config.
{
"budget": 8000,
"format": "md",
"tree": true,
"include": ["src/**", "docs/**"],
"exclude": ["**/*.log", "build/**"],
"max_file_tokens": 4000
}cctxpack discovers tracked/untracked files (respecting .gitignore), applies include/exclude + root filters, sorts deterministically, renders each file into the chosen format with per-section estimates, fits inside --budget (reserving header/tree/closing overhead), then emits a single bundle. Token estimates are computed on the rendered sections (with first=0), so budget math matches the final output.
| Code | Meaning |
|---|---|
| 0 | OK |
| 1 | I/O/OOM/read error |
| 2 | Usage/config error (invalid JSON, unknown flag, etc.) |
| 3 | Nothing fits the budget |
| 4 | --find ran fine but matched nothing |
When you know a token exists but not where, --find answers "which file, which
line" without building a bundle. It is a fixed-string, line-oriented search —
grep -F semantics — so it needs no regex engine and behaves identically on
every platform. Matches print as path:line: text:
cctxpack --find pack_run --include 'src/*'src/cmd.c:264: return pack_run(o);
src/main.c:54: return pack_run(&o);
src/pack.c:396: int pack_run(const pack_opts *o)
src/pack.h:39: int pack_run(const pack_opts *o);
- Honours path arguments,
--includeand--exclude, so it searches exactly what a bundle would have contained. - Skips binary files rather than printing their bytes as if they were text.
--reportaddsN matches in M fileson stderr;--reportstill goes to stderr, so piping matches to another command stays clean.-o FILEwrites the matches to a file instead of stdout.
Exit codes are script-friendly: 0 means at least one match, 4 means the
search ran but found nothing (distinct from 1 = I/O error and 2 = usage
error), so if cctxpack --find "$sym" >/dev/null; then ... works without
parsing output. An empty pattern is a usage error, not a match-everything
search.
| Aspect | cctxpack | repomix |
|---|---|---|
| Runtime | C (single native binary) | Node.js/TypeScript |
| Dependencies | Vendored only (offline) | Node deps (npm) |
| Token counting | script-aware heuristic, 8 per-language rates (+10.8% aggregate / up to −26% per file) | exact BPE (tiktoken cl100k_base) |
| Formats | md, xml, json, txt | md (primary), xml/json/txt variants |
| Git-aware | diff, diff --staged (NUL-split, worktree-aware) |
No native diff mode |
| Selection | Path-weighted --query + globs + sort (mtime/size/name) |
Globs/ignore + some ordering |
| Determinism | Byte-stable across runs (no locale) | Mostly stable; depends on deps/formatting |
| Footprint | 246 KB binary, 8.2 ms startup | Node runtime + npm dep tree |
| Use case | CLI-first, air-gapped, scriptable, reproducible | Polished TS UX |
Honest trade-off: repomix's token counts are exact (tiktoken) while ours are a calibrated script-aware heuristic. We buy a 246 KB dependency-free binary for that, and we bias high — over-estimating wastes a little budget, under-estimating fails the request.
Counts are estimates (est.), labelled as such everywhere. There are two
different numbers, and conflating them is how the previous version of this
README ended up claiming "+0.61% accurate", which was wrong.
Aggregate — "how many tokens is this repo?"
| Corpus | Files | Aggregate | Files under-estimated |
|---|---|---|---|
| a C++ repo | 99 | +24.6% | 1.0% |
| this repository (C) | 64 | +10.4% | 26.6% |
| a Markdown-heavy repo | 336 | +30.2% | 2.7% |
Per-file — "how many tokens is this file?"
| Corpus | Median | p90 | Worst | Under |
|---|---|---|---|---|
| a C++ repo | +32.2% | +52.0% | −2.5% | 1.0% |
| this repository (C) | +5.6% | +36.8% | −26.1% | 26.6% |
| a Markdown-heavy repo | +31.4% | +46.0% | −17.3% | 2.7% |
Reproduce all of it with:
python tools/benchmark.py "<repo-a>" "<repo-b>" "<repo-c>"The harness shells out to the real binary rather than reimplementing the
estimator in Python, and excludes binary files (cctxpack does not estimate them).
Results land in tools/benchmark-results.md.
Prose over-estimates more than code. The Markdown corpus sits ~6 points higher than C++ at the median — English prose is full of short common words that BPE merges aggressively, so a per-character rate runs high. CJK text over-estimates least (+22% median on the 9 CJK files sampled) but has the thinnest evidence behind it.
Why aggregate is so much better than per-file: per-file errors are close to
zero-mean, so they cancel across a repo — measured summed error is ~0.1% of
the corpus total, against a 2.1% per-file spread. A budget set from the
aggregate is reliable; a per-file number in count output is not, and is
labelled as an estimate for that reason.
The per-file floor is not a modelling deficiency. Two text chunks with
identical character-class, line-length, word-length and whitespace
statistics differ by a median of ~8% and ~25% at p90 in true token count,
because BPE merges common subwords — the is 1 token, zqjx is 3, at identical
character rates. Recovering that needs the vocabulary, which is a 2.3 MB table
we do not ship (see the design doc). Distinct-word-count features were measured
and do nothing; the missing information is vocabulary identity, which no
single-pass byte histogram can carry.
Two more things worth knowing:
- We deliberately over-estimate on non-ASCII content. The previous model
charged every character the ASCII rate, which put CJK text 48% under at the
median and 86% under at worst — so
--budget 100000could really emit 186,000 tokens and overflow the context window. - Binary files are not estimated. A NUL byte in the first 8 KB marks a file binary; cctxpack emits a placeholder carrying its path and byte size instead.
Reproduce with python tools/calibrate_tokens.py --compare and
tools/benchmark.py; full record in
design §4.6.
All numbers below were measured on this repository with both tools packing the same file set, on Windows 11 / MSVC Release build, cctxpack 0.1.0-dev vs repomix 1.18.1. Reproduce with the commands in the footnote.
| Metric | cctxpack | repomix 1.18.1 | |
|---|---|---|---|
| Files packed | 21 | 21 | same set |
| Tokens | 25,650 (heuristic estimate) | 26,239 (tiktoken cl100k_base) |
ours is −2.2% here; aggregate bias is +11.6% |
| Output size | 86,958 B | 88,583 B | |
| Wall time | 183 ms | ~2,550 ms | ~14× faster |
Startup only (--version) |
8.2 ms | — |
The two tools disagree on what to include, so these are not a like-for-like comparison — shown to document the difference:
| cctxpack | repomix | |
|---|---|---|
| Files | 62 | 47 |
| Tokens | 122,152 (est.) | 70,068 (tiktoken) |
| Output size | 421,172 B | 237,321 B |
| Wall time | 205 ms | ~2,550 ms |
cctxpack's larger total is mostly vendored code it respects as source:
vendor/greatest/greatest.h alone is 68,870 bytes / 19,348 est. tokens, and the
token estimator is known to over-count dense vendored text (§4.6 of the design
doc). repomix's default patterns exclude it. Use --exclude / --include to
match whichever corpus you want.
Of the 177 ms for src/**, almost none is file I/O: cctxpack --version
(startup only) is 8.2 ms and cctxpack tree . alone is 167 ms. The fixed cost
is three git subprocess spawns per run — git rev-parse --show-toplevel
for config discovery, git rev-parse --is-inside-work-tree, and
git ls-files -c -o --exclude-standard -z — each paying Windows process
creation plus git.exe load. Packing the 41 extra files on top of that costs
~36 ms.
Caveat: single-run-per-process timing on Windows includes PowerShell and
npx/Node startup overhead; npx alone adds a few hundred ms to the repomix
figure. The gap is large enough that this does not change the ranking, but the
absolute repomix number is slightly pessimistic.
# cctxpack
cctxpack count . --include "src/**" # files, bytes, lines, est. tokens
cctxpack . --include "src/**" -o out.md # wall time via Measure-Command / time
# repomix
npx -y repomix@latest . --include "src/**" --style markdown -o out.txtMIT License. See LICENSE for details.
Copyright (c) 2026 Nmd-Sketch.