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.
- ctxcat-0.1.0/.github/workflows/ci.yml +46 -0
- ctxcat-0.1.0/.gitignore +10 -0
- ctxcat-0.1.0/CHANGELOG.md +19 -0
- ctxcat-0.1.0/CONTRIBUTING.md +33 -0
- ctxcat-0.1.0/LICENSE +21 -0
- ctxcat-0.1.0/PKG-INFO +203 -0
- ctxcat-0.1.0/README.md +174 -0
- ctxcat-0.1.0/ctxcat/__init__.py +4 -0
- ctxcat-0.1.0/ctxcat/__main__.py +4 -0
- ctxcat-0.1.0/ctxcat/cli.py +489 -0
- ctxcat-0.1.0/docs/demo.gif +0 -0
- ctxcat-0.1.0/pyproject.toml +42 -0
- ctxcat-0.1.0/tests/test_ctxcat.py +156 -0
|
@@ -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
|
ctxcat-0.1.0/.gitignore
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/ctxcat/)
|
|
41
|
+
[](https://pypi.org/project/ctxcat/)
|
|
42
|
+
[](LICENSE)
|
|
43
|
+
[](https://github.com/kamilkubik89/ctxcat/actions)
|
|
44
|
+
[](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
|
+
[](https://pypi.org/project/ctxcat/)
|
|
12
|
+
[](https://pypi.org/project/ctxcat/)
|
|
13
|
+
[](LICENSE)
|
|
14
|
+
[](https://github.com/kamilkubik89/ctxcat/actions)
|
|
15
|
+
[](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,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("&", "&").replace("<", "<")
|
|
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}
|