ctxcat 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,46 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ tags: ["v*"]
7
+ pull_request:
8
+
9
+ jobs:
10
+ test:
11
+ name: Python ${{ matrix.python }} on ${{ matrix.os }}
12
+ runs-on: ${{ matrix.os }}
13
+ strategy:
14
+ fail-fast: false
15
+ matrix:
16
+ os: [ubuntu-latest, macos-latest, windows-latest]
17
+ python: ["3.9", "3.10", "3.11", "3.12", "3.13"]
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+ - uses: actions/setup-python@v5
21
+ with:
22
+ python-version: ${{ matrix.python }}
23
+ - name: Install
24
+ run: pip install pytest .
25
+ - name: Test
26
+ run: python -m pytest -v
27
+ - name: Smoke test (dogfood)
28
+ run: ctxcat . --list
29
+
30
+ publish:
31
+ name: Publish to PyPI
32
+ if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
33
+ needs: test
34
+ runs-on: ubuntu-latest
35
+ environment: pypi
36
+ permissions:
37
+ id-token: write
38
+ steps:
39
+ - uses: actions/checkout@v4
40
+ - uses: actions/setup-python@v5
41
+ with:
42
+ python-version: "3.12"
43
+ - name: Build
44
+ run: pip install build && python -m build
45
+ - name: Publish
46
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .pytest_cache/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .venv/
8
+ venv/
9
+ .idea/
10
+ .vscode/
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+ The format is based on [Keep a Changelog](https://keepachangelog.com/).
5
+
6
+ ## [0.1.0] - 2026-08-28
7
+
8
+ ### Added
9
+ - Initial release 🎉
10
+ - Pack any repository into Markdown, XML or plain text
11
+ - Git-aware file discovery (`git ls-files`) with full `.gitignore` support
12
+ - Fallback directory walker with sane default ignores (node_modules, lockfiles, binaries, .env, …)
13
+ - Token counting: tiktoken when available, ~4 chars/token heuristic otherwise
14
+ - `--max-tokens` budget with priority-based trimming (README/configs survive, tests go first)
15
+ - `--include` / `--exclude` glob filters
16
+ - `--copy` clipboard support (macOS, Windows, X11, Wayland)
17
+ - `--list` dry-run mode with per-file token counts
18
+ - Fence-safe Markdown output (handles files containing ```)
19
+ - Single-file implementation, zero runtime dependencies
@@ -0,0 +1,33 @@
1
+ # Contributing to ctxcat
2
+
3
+ Thanks for considering a contribution! ctxcat is intentionally tiny — the entire
4
+ tool lives in one file: `ctxcat/cli.py`.
5
+
6
+ ## Ground rules
7
+
8
+ 1. **Zero runtime dependencies.** This is the core promise. PRs adding required
9
+ dependencies will be declined (optional extras like `tiktoken` are fine).
10
+ 2. **One file.** The implementation stays in `cli.py`. If it can't fit, it
11
+ probably doesn't belong in ctxcat.
12
+ 3. **Every feature needs a test.** See `tests/test_ctxcat.py`.
13
+
14
+ ## Development setup
15
+
16
+ ```bash
17
+ git clone https://github.com/kamilkubik89/ctxcat
18
+ cd ctxcat
19
+ pip install pytest
20
+ python -m pytest # run the test suite
21
+ python -m ctxcat . # run from source
22
+ ```
23
+
24
+ ## Submitting changes
25
+
26
+ - Open an issue first for anything non-trivial.
27
+ - Keep PRs focused: one change per PR.
28
+ - Run `python -m pytest` before pushing.
29
+
30
+ ## Reporting bugs
31
+
32
+ Include: your OS, Python version (`python --version`), the exact command you
33
+ ran, and what you expected vs. what happened.
ctxcat-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ctxcat contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
ctxcat-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,203 @@
1
+ Metadata-Version: 2.5
2
+ Name: ctxcat
3
+ Version: 0.1.0
4
+ Summary: cat your repo into LLM context — pack any repository into a single, token-aware, LLM-ready document. Zero dependencies.
5
+ Project-URL: Homepage, https://github.com/kamilkubik89/ctxcat
6
+ Project-URL: Issues, https://github.com/kamilkubik89/ctxcat/issues
7
+ Project-URL: Changelog, https://github.com/kamilkubik89/ctxcat/blob/main/CHANGELOG.md
8
+ Author: ctxcat contributors
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: ai,chatgpt,claude,cli,codebase,context,gpt,llm,prompt,repository
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Software Development
24
+ Classifier: Topic :: Utilities
25
+ Requires-Python: >=3.9
26
+ Provides-Extra: accurate
27
+ Requires-Dist: tiktoken>=0.5; extra == 'accurate'
28
+ Description-Content-Type: text/markdown
29
+
30
+ <div align="center">
31
+
32
+ # 🐈 ctxcat
33
+
34
+ ### `cat` your repo into LLM context.
35
+
36
+ **Pack any repository into a single, clean, token-aware document — ready to paste into Claude, ChatGPT, Gemini or any LLM.**
37
+
38
+ One file. Zero dependencies. Respects `.gitignore`. Fits your context window.
39
+
40
+ [![PyPI](https://img.shields.io/pypi/v/ctxcat?color=blue)](https://pypi.org/project/ctxcat/)
41
+ [![Python](https://img.shields.io/badge/python-3.9%2B-blue)](https://pypi.org/project/ctxcat/)
42
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
43
+ [![CI](https://github.com/kamilkubik89/ctxcat/actions/workflows/ci.yml/badge.svg)](https://github.com/kamilkubik89/ctxcat/actions)
44
+ [![Zero dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](pyproject.toml)
45
+
46
+ <img src="docs/demo.gif" alt="ctxcat demo" width="700">
47
+
48
+ </div>
49
+
50
+ ---
51
+
52
+ ## Why?
53
+
54
+ You paste code into an LLM **dozens of times a day**. And every time it's the same dance:
55
+
56
+ 😩 open file → copy → paste → open next file → copy → paste → *"wait, which files did I already paste?"* → the model has no idea how your project is structured → you blow past the context window → start over.
57
+
58
+ **ctxcat ends the dance:**
59
+
60
+ ```bash
61
+ ctxcat --copy
62
+ ```
63
+
64
+ That's it. Your entire repo — file tree, every relevant file, properly fenced and labeled — is on your clipboard, trimmed to fit your context window. Paste it. Ask your question. Done.
65
+
66
+ ## Install
67
+
68
+ ```bash
69
+ pip install ctxcat
70
+ ```
71
+
72
+ Or with exact token counting (adds `tiktoken`):
73
+
74
+ ```bash
75
+ pip install 'ctxcat[accurate]'
76
+ ```
77
+
78
+ No `pip`? It's a **single file** — just grab it:
79
+
80
+ ```bash
81
+ curl -O https://raw.githubusercontent.com/kamilkubik89/ctxcat/main/ctxcat/cli.py
82
+ python3 cli.py --help
83
+ ```
84
+
85
+ ## Usage
86
+
87
+ ```bash
88
+ ctxcat # pack current dir → stdout
89
+ ctxcat ~/projects/myapp -o ctx.md # pack a repo → file
90
+ ctxcat --copy # pack → clipboard, ready to paste
91
+ ctxcat --list # what would be packed, with token counts
92
+ ctxcat -i 'src/**' -i '*.md' # only source + docs
93
+ ctxcat -x 'tests/*' -x '*.sql' # everything except tests and SQL
94
+ ctxcat --max-tokens 100000 # guarantee it fits a 100k window
95
+ ctxcat -f xml # XML output (great for Claude)
96
+ ctxcat -f txt | less # plain text, pipe-friendly
97
+ ```
98
+
99
+ ## What makes it smart
100
+
101
+ 🧠 **Git-aware.** Inside a git repo, ctxcat asks `git ls-files` — so it respects your `.gitignore` *exactly*, including nested and global ignores. No half-baked reimplementation.
102
+
103
+ ✂️ **Token budget with priorities.** `--max-tokens 100000` doesn't just truncate. It drops files in *reverse order of importance* — tests go first, then generic files, while your README, configs and core source survive. You always know what was trimmed.
104
+
105
+ 🧹 **Sane defaults.** `node_modules`, lockfiles, binaries, images, `.env`, build artifacts, ML model weights — automatically skipped. The stuff you'd never paste anyway.
106
+
107
+ 🔢 **Token counts, always.** Uses `tiktoken` when available, a proven ~4-chars/token heuristic otherwise. Every run tells you exactly how big your context is *before* you paste it.
108
+
109
+ 🛡️ **Fence-safe Markdown.** Files containing ` ``` ` won't break your output — ctxcat picks a longer fence automatically. It's the little things.
110
+
111
+ 📋 **Clipboard built in.** `--copy` works on macOS, Windows, X11 and Wayland. No plugins.
112
+
113
+ ## Example output
114
+
115
+ ````markdown
116
+ # Repository: myapp
117
+
118
+ ## File tree
119
+
120
+ ```
121
+ ├── src/
122
+ │ ├── main.py
123
+ │ └── util.py
124
+ ├── README.md
125
+ └── pyproject.toml
126
+ ```
127
+
128
+ ## Files
129
+
130
+ ### README.md
131
+ ...
132
+
133
+ ### src/main.py
134
+
135
+ ```python
136
+ def main():
137
+ ...
138
+ ```
139
+ ````
140
+
141
+ The LLM sees your project the way *you* see it: structure first, then code, every file labeled.
142
+
143
+ ## vs. alternatives
144
+
145
+ | | **ctxcat** | repomix | gitingest |
146
+ |---|:---:|:---:|:---:|
147
+ | Zero dependencies | ✅ | ❌ (Node) | ❌ |
148
+ | Single file, curl-able | ✅ | ❌ | ❌ |
149
+ | True `.gitignore` support (via git) | ✅ | partial | partial |
150
+ | Priority-based token budget | ✅ | ❌ | ❌ |
151
+ | Works offline, nothing leaves your machine | ✅ | ✅ | ⚠️ web service |
152
+ | Install size | ~15 KB | ~10 MB+ | — |
153
+
154
+ *(All of these are great projects — ctxcat just optimizes hard for simplicity.)*
155
+
156
+ ## All options
157
+
158
+ ```
159
+ ctxcat [path] [options]
160
+
161
+ -o, --output FILE write to FILE instead of stdout
162
+ -f, --format {md,xml,txt} output format (default: md)
163
+ -i, --include GLOB only include matching paths (repeatable)
164
+ -x, --exclude GLOB exclude matching paths (repeatable)
165
+ --max-tokens N trim lowest-priority files to fit N tokens
166
+ --max-file-kb KB skip files larger than KB (default: 256)
167
+ -c, --copy copy result to clipboard
168
+ -l, --list list files + token counts, don't pack
169
+ -q, --quiet no summary on stderr
170
+ --version print version
171
+ ```
172
+
173
+ ## Philosophy
174
+
175
+ 1. **Do one thing well.** Pack repo → LLM-ready text. That's it.
176
+ 2. **Zero friction.** No config file, no account, no server, no telemetry.
177
+ 3. **Your code stays yours.** Everything runs locally. Nothing is uploaded, ever.
178
+ 4. **Boring technology.** Pure Python stdlib. Auditable in one sitting — it's one file.
179
+
180
+ ## Contributing
181
+
182
+ PRs welcome! The whole tool is one file ([`ctxcat/cli.py`](ctxcat/cli.py)) with a test suite. Read it over coffee, break it, fix it.
183
+
184
+ ```bash
185
+ git clone https://github.com/kamilkubik89/ctxcat
186
+ cd ctxcat
187
+ python -m pytest # run tests
188
+ python -m ctxcat . # run from source
189
+ ```
190
+
191
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for details.
192
+
193
+ ## License
194
+
195
+ [MIT](LICENSE) — do whatever you want with it.
196
+
197
+ ---
198
+
199
+ <div align="center">
200
+
201
+ **If ctxcat saved you a copy-paste marathon, a ⭐ makes the cat purr.**
202
+
203
+ </div>
ctxcat-0.1.0/README.md ADDED
@@ -0,0 +1,174 @@
1
+ <div align="center">
2
+
3
+ # 🐈 ctxcat
4
+
5
+ ### `cat` your repo into LLM context.
6
+
7
+ **Pack any repository into a single, clean, token-aware document — ready to paste into Claude, ChatGPT, Gemini or any LLM.**
8
+
9
+ One file. Zero dependencies. Respects `.gitignore`. Fits your context window.
10
+
11
+ [![PyPI](https://img.shields.io/pypi/v/ctxcat?color=blue)](https://pypi.org/project/ctxcat/)
12
+ [![Python](https://img.shields.io/badge/python-3.9%2B-blue)](https://pypi.org/project/ctxcat/)
13
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
14
+ [![CI](https://github.com/kamilkubik89/ctxcat/actions/workflows/ci.yml/badge.svg)](https://github.com/kamilkubik89/ctxcat/actions)
15
+ [![Zero dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](pyproject.toml)
16
+
17
+ <img src="docs/demo.gif" alt="ctxcat demo" width="700">
18
+
19
+ </div>
20
+
21
+ ---
22
+
23
+ ## Why?
24
+
25
+ You paste code into an LLM **dozens of times a day**. And every time it's the same dance:
26
+
27
+ 😩 open file → copy → paste → open next file → copy → paste → *"wait, which files did I already paste?"* → the model has no idea how your project is structured → you blow past the context window → start over.
28
+
29
+ **ctxcat ends the dance:**
30
+
31
+ ```bash
32
+ ctxcat --copy
33
+ ```
34
+
35
+ That's it. Your entire repo — file tree, every relevant file, properly fenced and labeled — is on your clipboard, trimmed to fit your context window. Paste it. Ask your question. Done.
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ pip install ctxcat
41
+ ```
42
+
43
+ Or with exact token counting (adds `tiktoken`):
44
+
45
+ ```bash
46
+ pip install 'ctxcat[accurate]'
47
+ ```
48
+
49
+ No `pip`? It's a **single file** — just grab it:
50
+
51
+ ```bash
52
+ curl -O https://raw.githubusercontent.com/kamilkubik89/ctxcat/main/ctxcat/cli.py
53
+ python3 cli.py --help
54
+ ```
55
+
56
+ ## Usage
57
+
58
+ ```bash
59
+ ctxcat # pack current dir → stdout
60
+ ctxcat ~/projects/myapp -o ctx.md # pack a repo → file
61
+ ctxcat --copy # pack → clipboard, ready to paste
62
+ ctxcat --list # what would be packed, with token counts
63
+ ctxcat -i 'src/**' -i '*.md' # only source + docs
64
+ ctxcat -x 'tests/*' -x '*.sql' # everything except tests and SQL
65
+ ctxcat --max-tokens 100000 # guarantee it fits a 100k window
66
+ ctxcat -f xml # XML output (great for Claude)
67
+ ctxcat -f txt | less # plain text, pipe-friendly
68
+ ```
69
+
70
+ ## What makes it smart
71
+
72
+ 🧠 **Git-aware.** Inside a git repo, ctxcat asks `git ls-files` — so it respects your `.gitignore` *exactly*, including nested and global ignores. No half-baked reimplementation.
73
+
74
+ ✂️ **Token budget with priorities.** `--max-tokens 100000` doesn't just truncate. It drops files in *reverse order of importance* — tests go first, then generic files, while your README, configs and core source survive. You always know what was trimmed.
75
+
76
+ 🧹 **Sane defaults.** `node_modules`, lockfiles, binaries, images, `.env`, build artifacts, ML model weights — automatically skipped. The stuff you'd never paste anyway.
77
+
78
+ 🔢 **Token counts, always.** Uses `tiktoken` when available, a proven ~4-chars/token heuristic otherwise. Every run tells you exactly how big your context is *before* you paste it.
79
+
80
+ 🛡️ **Fence-safe Markdown.** Files containing ` ``` ` won't break your output — ctxcat picks a longer fence automatically. It's the little things.
81
+
82
+ 📋 **Clipboard built in.** `--copy` works on macOS, Windows, X11 and Wayland. No plugins.
83
+
84
+ ## Example output
85
+
86
+ ````markdown
87
+ # Repository: myapp
88
+
89
+ ## File tree
90
+
91
+ ```
92
+ ├── src/
93
+ │ ├── main.py
94
+ │ └── util.py
95
+ ├── README.md
96
+ └── pyproject.toml
97
+ ```
98
+
99
+ ## Files
100
+
101
+ ### README.md
102
+ ...
103
+
104
+ ### src/main.py
105
+
106
+ ```python
107
+ def main():
108
+ ...
109
+ ```
110
+ ````
111
+
112
+ The LLM sees your project the way *you* see it: structure first, then code, every file labeled.
113
+
114
+ ## vs. alternatives
115
+
116
+ | | **ctxcat** | repomix | gitingest |
117
+ |---|:---:|:---:|:---:|
118
+ | Zero dependencies | ✅ | ❌ (Node) | ❌ |
119
+ | Single file, curl-able | ✅ | ❌ | ❌ |
120
+ | True `.gitignore` support (via git) | ✅ | partial | partial |
121
+ | Priority-based token budget | ✅ | ❌ | ❌ |
122
+ | Works offline, nothing leaves your machine | ✅ | ✅ | ⚠️ web service |
123
+ | Install size | ~15 KB | ~10 MB+ | — |
124
+
125
+ *(All of these are great projects — ctxcat just optimizes hard for simplicity.)*
126
+
127
+ ## All options
128
+
129
+ ```
130
+ ctxcat [path] [options]
131
+
132
+ -o, --output FILE write to FILE instead of stdout
133
+ -f, --format {md,xml,txt} output format (default: md)
134
+ -i, --include GLOB only include matching paths (repeatable)
135
+ -x, --exclude GLOB exclude matching paths (repeatable)
136
+ --max-tokens N trim lowest-priority files to fit N tokens
137
+ --max-file-kb KB skip files larger than KB (default: 256)
138
+ -c, --copy copy result to clipboard
139
+ -l, --list list files + token counts, don't pack
140
+ -q, --quiet no summary on stderr
141
+ --version print version
142
+ ```
143
+
144
+ ## Philosophy
145
+
146
+ 1. **Do one thing well.** Pack repo → LLM-ready text. That's it.
147
+ 2. **Zero friction.** No config file, no account, no server, no telemetry.
148
+ 3. **Your code stays yours.** Everything runs locally. Nothing is uploaded, ever.
149
+ 4. **Boring technology.** Pure Python stdlib. Auditable in one sitting — it's one file.
150
+
151
+ ## Contributing
152
+
153
+ PRs welcome! The whole tool is one file ([`ctxcat/cli.py`](ctxcat/cli.py)) with a test suite. Read it over coffee, break it, fix it.
154
+
155
+ ```bash
156
+ git clone https://github.com/kamilkubik89/ctxcat
157
+ cd ctxcat
158
+ python -m pytest # run tests
159
+ python -m ctxcat . # run from source
160
+ ```
161
+
162
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for details.
163
+
164
+ ## License
165
+
166
+ [MIT](LICENSE) — do whatever you want with it.
167
+
168
+ ---
169
+
170
+ <div align="center">
171
+
172
+ **If ctxcat saved you a copy-paste marathon, a ⭐ makes the cat purr.**
173
+
174
+ </div>
@@ -0,0 +1,4 @@
1
+ """ctxcat — cat your repo into LLM context."""
2
+ from ctxcat.cli import __version__, main
3
+
4
+ __all__ = ["main", "__version__"]
@@ -0,0 +1,4 @@
1
+ from ctxcat.cli import main
2
+ import sys
3
+
4
+ sys.exit(main())
@@ -0,0 +1,489 @@
1
+ #!/usr/bin/env python3
2
+ """ctxcat — cat your repo into LLM context.
3
+
4
+ Packs an entire repository into a single, clean, token-aware document
5
+ ready to paste into Claude, ChatGPT, Gemini or any LLM.
6
+
7
+ Zero dependencies. Single file. Respects .gitignore.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import fnmatch
14
+ import os
15
+ import re
16
+ import subprocess
17
+ import sys
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+
21
+ __version__ = "0.1.0"
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Defaults
25
+ # ---------------------------------------------------------------------------
26
+
27
+ DEFAULT_IGNORE_DIRS = {
28
+ ".git", ".hg", ".svn", ".idea", ".vscode", "__pycache__",
29
+ "node_modules", "vendor", "dist", "build", "target", "out",
30
+ ".next", ".nuxt", ".svelte-kit", ".turbo", ".cache", ".parcel-cache",
31
+ ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox", ".venv", "venv",
32
+ "coverage", ".coverage", ".gradle", ".terraform", ".serverless",
33
+ "Pods", "DerivedData", ".dart_tool", ".angular",
34
+ }
35
+
36
+ DEFAULT_IGNORE_FILES = {
37
+ "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb",
38
+ "Cargo.lock", "poetry.lock", "Pipfile.lock", "composer.lock",
39
+ "Gemfile.lock", "go.sum", "uv.lock", "flake.lock",
40
+ ".DS_Store", "Thumbs.db", ".env",
41
+ }
42
+
43
+ BINARY_EXTENSIONS = {
44
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".icns",
45
+ ".svgz", ".tif", ".tiff", ".psd", ".ai", ".sketch",
46
+ ".mp3", ".mp4", ".wav", ".flac", ".ogg", ".avi", ".mov", ".mkv", ".webm",
47
+ ".zip", ".tar", ".gz", ".bz2", ".xz", ".zst", ".7z", ".rar", ".jar",
48
+ ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
49
+ ".exe", ".dll", ".so", ".dylib", ".bin", ".o", ".a", ".lib",
50
+ ".pyc", ".pyo", ".class", ".wasm", ".ttf", ".otf", ".woff", ".woff2",
51
+ ".eot", ".db", ".sqlite", ".sqlite3", ".parquet", ".pkl", ".pt", ".pth",
52
+ ".onnx", ".h5", ".npy", ".npz",
53
+ }
54
+
55
+ EXT_TO_LANG = {
56
+ ".py": "python", ".js": "javascript", ".jsx": "jsx", ".ts": "typescript",
57
+ ".tsx": "tsx", ".rb": "ruby", ".go": "go", ".rs": "rust", ".java": "java",
58
+ ".kt": "kotlin", ".swift": "swift", ".c": "c", ".h": "c", ".cpp": "cpp",
59
+ ".hpp": "cpp", ".cs": "csharp", ".php": "php", ".sh": "bash",
60
+ ".bash": "bash", ".zsh": "bash", ".fish": "fish", ".ps1": "powershell",
61
+ ".html": "html", ".htm": "html", ".css": "css", ".scss": "scss",
62
+ ".sass": "sass", ".less": "less", ".json": "json", ".yaml": "yaml",
63
+ ".yml": "yaml", ".toml": "toml", ".xml": "xml", ".md": "markdown",
64
+ ".rst": "rst", ".sql": "sql", ".graphql": "graphql", ".proto": "protobuf",
65
+ ".dockerfile": "dockerfile", ".tf": "hcl", ".vue": "vue",
66
+ ".svelte": "svelte", ".dart": "dart", ".ex": "elixir", ".exs": "elixir",
67
+ ".erl": "erlang", ".hs": "haskell", ".lua": "lua", ".r": "r",
68
+ ".scala": "scala", ".clj": "clojure", ".zig": "zig", ".nim": "nim",
69
+ }
70
+
71
+ # Files that matter most when trimming to a token budget.
72
+ # Checked in order; source code (priority 2) is the default.
73
+ PRIORITY_PATTERNS = [
74
+ (0, ["readme*", "*.md"]),
75
+ (1, ["pyproject.toml", "package.json", "cargo.toml", "go.mod",
76
+ "makefile", "dockerfile", "docker-compose*", "*.toml", "*.yaml", "*.yml"]),
77
+ (3, ["tests/*", "test/*", "*_test.*", "test_*", "*.spec.*", "*.test.*"]),
78
+ ]
79
+ DEFAULT_PRIORITY = 2
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Data
84
+ # ---------------------------------------------------------------------------
85
+
86
+ @dataclass
87
+ class RepoFile:
88
+ path: Path # absolute
89
+ rel: str # relative, posix-style
90
+ content: str = ""
91
+ tokens: int = 0
92
+ priority: int = 2
93
+
94
+
95
+ @dataclass
96
+ class PackResult:
97
+ files: list[RepoFile] = field(default_factory=list)
98
+ skipped: list[str] = field(default_factory=list)
99
+ trimmed: list[str] = field(default_factory=list)
100
+ total_tokens: int = 0
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Token counting
105
+ # ---------------------------------------------------------------------------
106
+
107
+ def make_token_counter():
108
+ """Return (fn, name). Uses tiktoken when available, else a heuristic."""
109
+ try:
110
+ import tiktoken # type: ignore
111
+ enc = tiktoken.get_encoding("cl100k_base")
112
+ return (lambda text: len(enc.encode(text, disallowed_special=()))), "tiktoken/cl100k"
113
+ except Exception:
114
+ # ~4 chars per token is a solid approximation for code in English.
115
+ return (lambda text: max(1, (len(text) + 3) // 4)), "heuristic (~4 chars/token)"
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # File discovery
120
+ # ---------------------------------------------------------------------------
121
+
122
+ def git_ls_files(root: Path) -> list[str] | None:
123
+ """Fast path: let git tell us what's tracked/untracked-but-not-ignored."""
124
+ try:
125
+ out = subprocess.run(
126
+ ["git", "-C", str(root), "ls-files", "--cached", "--others",
127
+ "--exclude-standard", "-z"],
128
+ capture_output=True, check=True, timeout=15,
129
+ )
130
+ entries = [e for e in out.stdout.decode("utf-8", "replace").split("\0") if e]
131
+ return entries
132
+ except Exception:
133
+ return None
134
+
135
+
136
+ def walk_files(root: Path) -> list[str]:
137
+ """Fallback: manual walk with sane default ignores."""
138
+ results: list[str] = []
139
+ for dirpath, dirnames, filenames in os.walk(root):
140
+ dirnames[:] = sorted(
141
+ d for d in dirnames
142
+ if d not in DEFAULT_IGNORE_DIRS and not d.startswith(".")
143
+ )
144
+ for name in sorted(filenames):
145
+ rel = os.path.relpath(os.path.join(dirpath, name), root)
146
+ results.append(rel.replace(os.sep, "/"))
147
+ return results
148
+
149
+
150
+ def is_binary(path: Path) -> bool:
151
+ if path.suffix.lower() in BINARY_EXTENSIONS:
152
+ return True
153
+ try:
154
+ with open(path, "rb") as f:
155
+ chunk = f.read(2048)
156
+ return b"\0" in chunk
157
+ except OSError:
158
+ return True
159
+
160
+
161
+ def matches_any(rel: str, patterns: list[str]) -> bool:
162
+ name = rel.rsplit("/", 1)[-1]
163
+ for pat in patterns:
164
+ if fnmatch.fnmatch(rel, pat) or fnmatch.fnmatch(name, pat):
165
+ return True
166
+ # allow directory-style patterns like "src/" or "src"
167
+ if rel.startswith(pat.rstrip("/") + "/"):
168
+ return True
169
+ return False
170
+
171
+
172
+ def priority_of(rel: str) -> int:
173
+ low = rel.lower()
174
+ name = low.rsplit("/", 1)[-1]
175
+ for prio, pats in PRIORITY_PATTERNS:
176
+ for pat in pats:
177
+ if fnmatch.fnmatch(low, pat) or fnmatch.fnmatch(name, pat):
178
+ return prio
179
+ return DEFAULT_PRIORITY
180
+
181
+
182
+ # ---------------------------------------------------------------------------
183
+ # Packing
184
+ # ---------------------------------------------------------------------------
185
+
186
+ def collect(
187
+ root: Path,
188
+ include: list[str],
189
+ exclude: list[str],
190
+ max_file_kb: int,
191
+ count_tokens,
192
+ ) -> PackResult:
193
+ result = PackResult()
194
+ rels = git_ls_files(root)
195
+ if rels is None:
196
+ rels = walk_files(root)
197
+
198
+ seen = set()
199
+ for rel in rels:
200
+ if rel in seen:
201
+ continue
202
+ seen.add(rel)
203
+ path = root / rel
204
+ if not path.is_file():
205
+ continue
206
+
207
+ parts = rel.split("/")
208
+ name = parts[-1]
209
+
210
+ if any(p in DEFAULT_IGNORE_DIRS for p in parts[:-1]):
211
+ result.skipped.append(f"{rel} (ignored dir)")
212
+ continue
213
+ if name in DEFAULT_IGNORE_FILES:
214
+ result.skipped.append(f"{rel} (lockfile/junk)")
215
+ continue
216
+ if exclude and matches_any(rel, exclude):
217
+ result.skipped.append(f"{rel} (--exclude)")
218
+ continue
219
+ if include and not matches_any(rel, include):
220
+ continue
221
+ if is_binary(path):
222
+ result.skipped.append(f"{rel} (binary)")
223
+ continue
224
+ try:
225
+ size_kb = path.stat().st_size / 1024
226
+ except OSError:
227
+ continue
228
+ if size_kb > max_file_kb:
229
+ result.skipped.append(f"{rel} ({size_kb:.0f} KB > --max-file-kb {max_file_kb})")
230
+ continue
231
+ try:
232
+ content = path.read_text(encoding="utf-8", errors="replace")
233
+ except OSError:
234
+ result.skipped.append(f"{rel} (unreadable)")
235
+ continue
236
+
237
+ rf = RepoFile(path=path, rel=rel, content=content,
238
+ tokens=count_tokens(content), priority=priority_of(rel))
239
+ result.files.append(rf)
240
+
241
+ result.files.sort(key=lambda f: (f.priority, f.rel))
242
+ result.total_tokens = sum(f.tokens for f in result.files)
243
+ return result
244
+
245
+
246
+ def apply_budget(result: PackResult, max_tokens: int, overhead: int) -> None:
247
+ """Drop lowest-priority files (from the end) until we fit the budget."""
248
+ budget = max_tokens - overhead
249
+ while result.files and result.total_tokens > budget:
250
+ dropped = result.files.pop() # list is sorted best-first
251
+ result.total_tokens -= dropped.tokens
252
+ result.trimmed.append(f"{dropped.rel} (~{dropped.tokens:,} tokens)")
253
+ result.trimmed.reverse()
254
+
255
+
256
+ # ---------------------------------------------------------------------------
257
+ # Rendering
258
+ # ---------------------------------------------------------------------------
259
+
260
+ def build_tree(rels: list[str]) -> str:
261
+ """Render a compact directory tree from relative paths."""
262
+ tree: dict = {}
263
+ for rel in rels:
264
+ node = tree
265
+ for part in rel.split("/"):
266
+ node = node.setdefault(part, {})
267
+
268
+ lines: list[str] = []
269
+
270
+ def render(node: dict, prefix: str) -> None:
271
+ entries = sorted(node.items(), key=lambda kv: (not kv[1], kv[0].lower()))
272
+ for i, (name, child) in enumerate(entries):
273
+ last = i == len(entries) - 1
274
+ connector = "└── " if last else "├── "
275
+ lines.append(prefix + connector + name + ("/" if child else ""))
276
+ if child:
277
+ render(child, prefix + (" " if last else "│ "))
278
+
279
+ render(tree, "")
280
+ return "\n".join(lines)
281
+
282
+
283
+ def lang_for(rel: str) -> str:
284
+ name = rel.rsplit("/", 1)[-1].lower()
285
+ if name == "dockerfile":
286
+ return "dockerfile"
287
+ if name == "makefile":
288
+ return "makefile"
289
+ return EXT_TO_LANG.get(Path(rel).suffix.lower(), "")
290
+
291
+
292
+ def pick_fence(content: str) -> str:
293
+ """Choose a backtick fence longer than any run of backticks in content."""
294
+ longest = 0
295
+ for m in re.finditer(r"`+", content):
296
+ longest = max(longest, len(m.group(0)))
297
+ return "`" * max(3, longest + 1)
298
+
299
+
300
+ def render_markdown(root: Path, result: PackResult) -> str:
301
+ parts = [
302
+ f"# Repository: {root.name}",
303
+ "",
304
+ "This document contains the full source of the repository, "
305
+ "packed for LLM consumption by ctxcat.",
306
+ "",
307
+ "## File tree",
308
+ "",
309
+ "```",
310
+ build_tree([f.rel for f in result.files]),
311
+ "```",
312
+ "",
313
+ "## Files",
314
+ "",
315
+ ]
316
+ for f in result.files:
317
+ fence = pick_fence(f.content)
318
+ lang = lang_for(f.rel)
319
+ parts.append(f"### {f.rel}")
320
+ parts.append("")
321
+ parts.append(f"{fence}{lang}")
322
+ parts.append(f.content.rstrip("\n"))
323
+ parts.append(fence)
324
+ parts.append("")
325
+ return "\n".join(parts)
326
+
327
+
328
+ def render_xml(root: Path, result: PackResult) -> str:
329
+ def esc(s: str) -> str:
330
+ return s.replace("&", "&amp;").replace("<", "&lt;")
331
+
332
+ parts = [
333
+ f'<repository name="{root.name}" packed_by="ctxcat">',
334
+ "<file_tree>",
335
+ esc(build_tree([f.rel for f in result.files])),
336
+ "</file_tree>",
337
+ ]
338
+ for f in result.files:
339
+ parts.append(f'<file path="{f.rel}">')
340
+ parts.append(esc(f.content.rstrip("\n")))
341
+ parts.append("</file>")
342
+ parts.append("</repository>")
343
+ return "\n".join(parts)
344
+
345
+
346
+ def render_plain(root: Path, result: PackResult) -> str:
347
+ sep = "=" * 72
348
+ parts = [f"Repository: {root.name}", ""]
349
+ for f in result.files:
350
+ parts += [sep, f"FILE: {f.rel}", sep, f.content.rstrip("\n"), ""]
351
+ return "\n".join(parts)
352
+
353
+
354
+ RENDERERS = {"md": render_markdown, "xml": render_xml, "txt": render_plain}
355
+
356
+
357
+ # ---------------------------------------------------------------------------
358
+ # Clipboard
359
+ # ---------------------------------------------------------------------------
360
+
361
+ def copy_to_clipboard(text: str) -> bool:
362
+ cmds = []
363
+ if sys.platform == "darwin":
364
+ cmds = [["pbcopy"]]
365
+ elif os.name == "nt":
366
+ cmds = [["clip"]]
367
+ else:
368
+ cmds = [["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "-b"]]
369
+ for cmd in cmds:
370
+ try:
371
+ subprocess.run(cmd, input=text.encode(), check=True, timeout=10)
372
+ return True
373
+ except Exception:
374
+ continue
375
+ return False
376
+
377
+
378
+ # ---------------------------------------------------------------------------
379
+ # CLI
380
+ # ---------------------------------------------------------------------------
381
+
382
+ def human(n: int) -> str:
383
+ return f"{n:,}"
384
+
385
+
386
+ def main(argv: list[str] | None = None) -> int:
387
+ """Entry point; exits cleanly when piped into head/less."""
388
+ try:
389
+ return _main(argv)
390
+ except BrokenPipeError:
391
+ # Piped into `head`, `less` etc. and the reader closed early — fine.
392
+ devnull = os.open(os.devnull, os.O_WRONLY)
393
+ os.dup2(devnull, sys.stdout.fileno())
394
+ return 0
395
+
396
+
397
+ def _main(argv: list[str] | None = None) -> int:
398
+ p = argparse.ArgumentParser(
399
+ prog="ctxcat",
400
+ description="Pack a repository into a single LLM-ready document.",
401
+ epilog="Examples:\n"
402
+ " ctxcat # pack current dir to stdout\n"
403
+ " ctxcat ~/proj -o context.md # write to a file\n"
404
+ " ctxcat -i 'src/**' -i '*.md' # only src/ and markdown\n"
405
+ " ctxcat -x 'tests/*' --copy # skip tests, copy to clipboard\n"
406
+ " ctxcat --max-tokens 100000 # fit a 100k context window\n",
407
+ formatter_class=argparse.RawDescriptionHelpFormatter,
408
+ )
409
+ p.add_argument("path", nargs="?", default=".", help="repository root (default: .)")
410
+ p.add_argument("-o", "--output", metavar="FILE", help="write output to FILE instead of stdout")
411
+ p.add_argument("-f", "--format", choices=sorted(RENDERERS), default="md",
412
+ help="output format (default: md)")
413
+ p.add_argument("-i", "--include", action="append", default=[], metavar="GLOB",
414
+ help="only include paths matching GLOB (repeatable)")
415
+ p.add_argument("-x", "--exclude", action="append", default=[], metavar="GLOB",
416
+ help="exclude paths matching GLOB (repeatable)")
417
+ p.add_argument("--max-tokens", type=int, metavar="N",
418
+ help="trim lowest-priority files to fit N tokens")
419
+ p.add_argument("--max-file-kb", type=int, default=256, metavar="KB",
420
+ help="skip files larger than KB kilobytes (default: 256)")
421
+ p.add_argument("-c", "--copy", action="store_true", help="copy result to clipboard")
422
+ p.add_argument("-l", "--list", action="store_true",
423
+ help="list files and token counts, don't pack")
424
+ p.add_argument("-q", "--quiet", action="store_true", help="suppress the summary on stderr")
425
+ p.add_argument("--version", action="version", version=f"ctxcat {__version__}")
426
+ args = p.parse_args(argv)
427
+
428
+ root = Path(args.path).expanduser().resolve()
429
+ if not root.is_dir():
430
+ print(f"ctxcat: error: not a directory: {root}", file=sys.stderr)
431
+ return 2
432
+
433
+ count_tokens, counter_name = make_token_counter()
434
+ result = collect(root, args.include, args.exclude, args.max_file_kb, count_tokens)
435
+
436
+ if not result.files:
437
+ print("ctxcat: no matching text files found.", file=sys.stderr)
438
+ return 1
439
+
440
+ if args.list:
441
+ width = max(len(f.rel) for f in result.files)
442
+ for f in result.files:
443
+ print(f"{f.rel:<{width}} ~{human(f.tokens)} tokens")
444
+ print(f"\n{len(result.files)} files, ~{human(result.total_tokens)} tokens "
445
+ f"({counter_name})")
446
+ return 0
447
+
448
+ if args.max_tokens:
449
+ overhead = count_tokens(build_tree([f.rel for f in result.files])) + 200
450
+ apply_budget(result, args.max_tokens, overhead)
451
+ if not result.files:
452
+ print("ctxcat: error: --max-tokens too small to fit anything.", file=sys.stderr)
453
+ return 1
454
+
455
+ output = RENDERERS[args.format](root, result)
456
+ final_tokens = count_tokens(output)
457
+
458
+ if args.output:
459
+ Path(args.output).write_text(output, encoding="utf-8")
460
+ elif not args.copy:
461
+ print(output)
462
+
463
+ if args.copy:
464
+ if copy_to_clipboard(output):
465
+ if not args.quiet:
466
+ print("✔ copied to clipboard", file=sys.stderr)
467
+ else:
468
+ print("ctxcat: warning: no clipboard tool found "
469
+ "(install xclip / wl-clipboard), printing instead.", file=sys.stderr)
470
+ print(output)
471
+
472
+ if not args.quiet:
473
+ print(f"\n📦 {len(result.files)} files packed · "
474
+ f"~{human(final_tokens)} tokens ({counter_name})", file=sys.stderr)
475
+ if result.trimmed:
476
+ print(f"✂ trimmed {len(result.trimmed)} files to fit --max-tokens:",
477
+ file=sys.stderr)
478
+ for t in result.trimmed[:10]:
479
+ print(f" - {t}", file=sys.stderr)
480
+ if len(result.trimmed) > 10:
481
+ print(f" … and {len(result.trimmed) - 10} more", file=sys.stderr)
482
+ if args.output:
483
+ print(f"→ written to {args.output}", file=sys.stderr)
484
+
485
+ return 0
486
+
487
+
488
+ if __name__ == "__main__":
489
+ sys.exit(main())
Binary file
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ctxcat"
7
+ version = "0.1.0"
8
+ description = "cat your repo into LLM context — pack any repository into a single, token-aware, LLM-ready document. Zero dependencies."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "ctxcat contributors" }]
13
+ keywords = ["llm", "ai", "context", "prompt", "repository", "cli", "claude", "chatgpt", "gpt", "codebase"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Software Development",
27
+ "Topic :: Utilities",
28
+ ]
29
+
30
+ [project.optional-dependencies]
31
+ accurate = ["tiktoken>=0.5"]
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/kamilkubik89/ctxcat"
35
+ Issues = "https://github.com/kamilkubik89/ctxcat/issues"
36
+ Changelog = "https://github.com/kamilkubik89/ctxcat/blob/main/CHANGELOG.md"
37
+
38
+ [project.scripts]
39
+ ctxcat = "ctxcat.cli:main"
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["ctxcat"]
@@ -0,0 +1,156 @@
1
+ """Tests for ctxcat. Run with: python -m pytest"""
2
+
3
+ import subprocess
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ sys.path.insert(0, str(Path(__file__).parent.parent))
10
+
11
+ from ctxcat.cli import ( # noqa: E402
12
+ apply_budget, build_tree, collect, main, make_token_counter,
13
+ pick_fence, priority_of, PackResult, RepoFile,
14
+ )
15
+
16
+
17
+ @pytest.fixture
18
+ def repo(tmp_path: Path) -> Path:
19
+ (tmp_path / "README.md").write_text("# Demo\nHello.")
20
+ (tmp_path / "src").mkdir()
21
+ (tmp_path / "src" / "main.py").write_text("print('hi')\n")
22
+ (tmp_path / "src" / "util.py").write_text("def f():\n return 42\n")
23
+ (tmp_path / "tests").mkdir()
24
+ (tmp_path / "tests" / "test_main.py").write_text("def test():\n pass\n")
25
+ (tmp_path / "node_modules").mkdir()
26
+ (tmp_path / "node_modules" / "junk.js").write_text("x" * 1000)
27
+ (tmp_path / "package-lock.json").write_text("{}")
28
+ (tmp_path / "logo.png").write_bytes(b"\x89PNG\r\n\x1a\n\x00\x00")
29
+ return tmp_path
30
+
31
+
32
+ def _collect(root: Path, include=None, exclude=None, max_kb=256):
33
+ count, _ = make_token_counter()
34
+ return collect(root, include or [], exclude or [], max_kb, count)
35
+
36
+
37
+ def test_collect_finds_text_files(repo):
38
+ result = _collect(repo)
39
+ rels = {f.rel for f in result.files}
40
+ assert "README.md" in rels
41
+ assert "src/main.py" in rels
42
+ assert "tests/test_main.py" in rels
43
+
44
+
45
+ def test_collect_skips_junk(repo):
46
+ result = _collect(repo)
47
+ rels = {f.rel for f in result.files}
48
+ assert "node_modules/junk.js" not in rels
49
+ assert "package-lock.json" not in rels
50
+ assert "logo.png" not in rels
51
+
52
+
53
+ def test_priority_ordering(repo):
54
+ result = _collect(repo)
55
+ # README (prio 0) must come before source (prio 2) before tests (prio 3)
56
+ order = [f.rel for f in result.files]
57
+ assert order.index("README.md") < order.index("src/main.py")
58
+ assert order.index("src/main.py") < order.index("tests/test_main.py")
59
+
60
+
61
+ def test_include_filter(repo):
62
+ result = _collect(repo, include=["src/*"])
63
+ rels = {f.rel for f in result.files}
64
+ assert rels == {"src/main.py", "src/util.py"}
65
+
66
+
67
+ def test_exclude_filter(repo):
68
+ result = _collect(repo, exclude=["tests/*"])
69
+ rels = {f.rel for f in result.files}
70
+ assert "tests/test_main.py" not in rels
71
+ assert "src/main.py" in rels
72
+
73
+
74
+ def test_max_file_kb(repo):
75
+ (repo / "big.txt").write_text("x" * 500_000)
76
+ result = _collect(repo, max_kb=100)
77
+ assert "big.txt" not in {f.rel for f in result.files}
78
+
79
+
80
+ def test_budget_trims_lowest_priority_first():
81
+ files = [
82
+ RepoFile(Path("a"), "README.md", "r" * 40, tokens=10, priority=0),
83
+ RepoFile(Path("b"), "src/main.py", "m" * 40, tokens=10, priority=2),
84
+ RepoFile(Path("c"), "tests/test_x.py", "t" * 40, tokens=10, priority=3),
85
+ ]
86
+ result = PackResult(files=list(files), total_tokens=30)
87
+ apply_budget(result, max_tokens=25, overhead=0)
88
+ kept = {f.rel for f in result.files}
89
+ assert "README.md" in kept
90
+ assert "tests/test_x.py" not in kept
91
+
92
+
93
+ def test_tree_rendering():
94
+ tree = build_tree(["src/main.py", "src/util.py", "README.md"])
95
+ assert "src/" in tree
96
+ assert "main.py" in tree
97
+ assert "README.md" in tree
98
+
99
+
100
+ def test_fence_escaping():
101
+ assert pick_fence("no backticks") == "```"
102
+ assert pick_fence("``` inside") == "````"
103
+ assert pick_fence("`````") == "``````"
104
+
105
+
106
+ def test_priority_of():
107
+ assert priority_of("README.md") == 0
108
+ assert priority_of("pyproject.toml") == 1
109
+ assert priority_of("src/app.py") == 2
110
+ assert priority_of("tests/test_app.py") == 3
111
+
112
+
113
+ def test_cli_end_to_end(repo, capsys):
114
+ rc = main([str(repo), "-q"])
115
+ assert rc == 0
116
+ out = capsys.readouterr().out
117
+ assert "# Repository:" in out
118
+ assert "src/main.py" in out
119
+ assert "print('hi')" in out
120
+
121
+
122
+ def test_cli_list_mode(repo, capsys):
123
+ rc = main([str(repo), "--list"])
124
+ assert rc == 0
125
+ out = capsys.readouterr().out
126
+ assert "tokens" in out
127
+ assert "README.md" in out
128
+
129
+
130
+ def test_cli_xml_format(repo, capsys):
131
+ rc = main([str(repo), "-f", "xml", "-q"])
132
+ assert rc == 0
133
+ out = capsys.readouterr().out
134
+ assert "<repository" in out
135
+ assert '<file path="src/main.py">' in out
136
+
137
+
138
+ def test_cli_output_file(repo, tmp_path):
139
+ dest = tmp_path / "out.md"
140
+ rc = main([str(repo), "-o", str(dest), "-q"])
141
+ assert rc == 0
142
+ assert dest.exists()
143
+ assert "src/main.py" in dest.read_text()
144
+
145
+
146
+ def test_cli_bad_path(capsys):
147
+ rc = main(["/definitely/not/a/dir/xyz", "-q"])
148
+ assert rc == 2
149
+
150
+
151
+ def test_git_repo_respects_gitignore(repo):
152
+ subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
153
+ (repo / ".gitignore").write_text("secret.txt\n")
154
+ (repo / "secret.txt").write_text("password=hunter2")
155
+ result = _collect(repo)
156
+ assert "secret.txt" not in {f.rel for f in result.files}