ctxcat 0.1.0__py3-none-any.whl
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/__init__.py +4 -0
- ctxcat/__main__.py +4 -0
- ctxcat/cli.py +489 -0
- ctxcat-0.1.0.dist-info/METADATA +203 -0
- ctxcat-0.1.0.dist-info/RECORD +8 -0
- ctxcat-0.1.0.dist-info/WHEEL +4 -0
- ctxcat-0.1.0.dist-info/entry_points.txt +2 -0
- ctxcat-0.1.0.dist-info/licenses/LICENSE +21 -0
ctxcat/__init__.py
ADDED
ctxcat/__main__.py
ADDED
ctxcat/cli.py
ADDED
|
@@ -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())
|
|
@@ -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>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
ctxcat/__init__.py,sha256=12-qgaPXXqadEGJrl22JlfJChMxb8JPPiSJTbDdLeus,125
|
|
2
|
+
ctxcat/__main__.py,sha256=PbDBGiCTyVdzSmIP_2nBcL6YgVtbWVDTaERSpIw9weo,57
|
|
3
|
+
ctxcat/cli.py,sha256=YB5Gfj0jHTs5a_SowNCLE6A32_5HCup0PCFU66DrKDI,17652
|
|
4
|
+
ctxcat-0.1.0.dist-info/METADATA,sha256=fQfSCVQdE-Uf0yLz5ILMeMdGNJFbQd9ZbGJoWnKiZUY,7043
|
|
5
|
+
ctxcat-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
6
|
+
ctxcat-0.1.0.dist-info/entry_points.txt,sha256=Cta5eDiE9KcXBzDf2AF59nOjqxDkOgb2Ijuu3vhTTnw,43
|
|
7
|
+
ctxcat-0.1.0.dist-info/licenses/LICENSE,sha256=ZZj4Lp-MoXCtziwLv0X5mZwp09fV1nkiH0ybxSrEKZ0,1076
|
|
8
|
+
ctxcat-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|