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 ADDED
@@ -0,0 +1,4 @@
1
+ """ctxcat โ€” cat your repo into LLM context."""
2
+ from ctxcat.cli import __version__, main
3
+
4
+ __all__ = ["main", "__version__"]
ctxcat/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from ctxcat.cli import main
2
+ import sys
3
+
4
+ sys.exit(main())
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("&", "&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())
@@ -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>
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ctxcat = ctxcat.cli:main
@@ -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.