repocxt 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.
ctx/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """ctx — package a repository into a single, LLM-ready context file.
2
+
3
+ The public surface is the ``ctx`` console command, implemented in
4
+ :mod:`ctx.cli`. Importing the package only exposes metadata.
5
+ """
6
+
7
+ __version__ = "0.1.0"
8
+ __all__ = ["__version__"]
ctx/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m ctx``."""
2
+
3
+ from ctx.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
ctx/binary.py ADDED
@@ -0,0 +1,37 @@
1
+ """Heuristic detection of binary files.
2
+
3
+ No file signature database: a file is treated as binary when its first chunk
4
+ contains a NUL byte, which is reliable for the formats programmers actually
5
+ commit (images, archives, compiled objects, fonts, audio). UTF-16 text and some
6
+ encodings can contain NUL, but they are vanishingly rare in repository trees.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ _SNIFF_BYTES = 8192
14
+ _NUL = b"\x00"
15
+
16
+
17
+ def is_nul_in_text(text: str) -> bool:
18
+ """True when decoded ``text`` still contains a NUL character.
19
+
20
+ ``is_binary`` sniffs only the first chunk, so a NUL past that window would
21
+ otherwise slip through; checking the decoded text is the definitive gate.
22
+ """
23
+ return _NUL.decode("ascii") in text
24
+
25
+
26
+ def is_binary(path: Path) -> bool:
27
+ """Return True when ``path`` looks like a binary file.
28
+
29
+ Reads at most :data:`_SNIFF_BYTES` bytes. Files shorter than that are judged
30
+ on their whole content. A missing file answers False (the caller handles it).
31
+ """
32
+ try:
33
+ with path.open("rb") as handle:
34
+ chunk = handle.read(_SNIFF_BYTES)
35
+ except OSError:
36
+ return False
37
+ return _NUL in chunk
ctx/cli.py ADDED
@@ -0,0 +1,189 @@
1
+ """Command-line interface for ctx."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from ctx import __version__
11
+ from ctx.binary import is_nul_in_text
12
+ from ctx.render import render_document
13
+ from ctx.tokens import estimate_tokens
14
+ from ctx.walker import discover
15
+
16
+ # Fixed output we always emit per file section; used to make --max-tokens honest.
17
+ _PER_FILE_OVERHEAD = 8 # "### `rel`", blank, fence ×2, blank — roughly
18
+
19
+
20
+ def build_parser() -> argparse.ArgumentParser:
21
+ parser = argparse.ArgumentParser(
22
+ prog="ctx",
23
+ description=(
24
+ "Package a repository into a single, LLM-ready context file with "
25
+ "a tree, file contents, and a token estimate."
26
+ ),
27
+ epilog="Exit status: 0 on success, 1 when the root is missing or nothing is packed.",
28
+ )
29
+ parser.add_argument(
30
+ "root",
31
+ nargs="?",
32
+ default=".",
33
+ help="directory to pack (default: current directory)",
34
+ )
35
+ parser.add_argument(
36
+ "-o",
37
+ "--output",
38
+ metavar="FILE",
39
+ help="write to FILE instead of stdout",
40
+ )
41
+ parser.add_argument(
42
+ "--include",
43
+ action="append",
44
+ default=[],
45
+ metavar="GLOB",
46
+ help="only include paths matching GLOB (repeatable; pathspec syntax)",
47
+ )
48
+ parser.add_argument(
49
+ "--exclude",
50
+ action="append",
51
+ default=[],
52
+ metavar="GLOB",
53
+ help="exclude paths matching GLOB (repeatable; pathspec syntax)",
54
+ )
55
+ parser.add_argument(
56
+ "--max-size",
57
+ type=int,
58
+ metavar="BYTES",
59
+ help="skip files larger than this many bytes",
60
+ )
61
+ parser.add_argument(
62
+ "--max-tokens",
63
+ type=int,
64
+ metavar="N",
65
+ help="pack files until the estimated output reaches N tokens",
66
+ )
67
+ parser.add_argument(
68
+ "--no-tree",
69
+ action="store_true",
70
+ help="omit the ASCII tree section",
71
+ )
72
+ parser.add_argument(
73
+ "--json",
74
+ action="store_true",
75
+ help="print a JSON summary instead of the document",
76
+ )
77
+ parser.add_argument(
78
+ "--version",
79
+ action="version",
80
+ version=f"ctx {__version__}",
81
+ )
82
+ return parser
83
+
84
+
85
+ def main(argv: list[str] | None = None) -> int:
86
+ args = build_parser().parse_args(argv)
87
+
88
+ root = Path(args.root)
89
+ if not root.is_dir():
90
+ print(f"ctx: {args.root}: no such directory", file=sys.stderr)
91
+ return 1
92
+
93
+ exclude = list(args.exclude)
94
+ output_path: Path | None = None
95
+ if args.output and args.output != "-":
96
+ output_path = Path(args.output).resolve()
97
+ # Don't pack the file we are about to write (or one from a previous run).
98
+ try:
99
+ excluded_above = output_path.relative_to(root.resolve())
100
+ exclude.append(str(excluded_above))
101
+ except ValueError:
102
+ pass # output lives outside the packed tree; nothing to do
103
+
104
+ files = discover(
105
+ root,
106
+ include=tuple(args.include),
107
+ exclude=tuple(exclude),
108
+ max_size=args.max_size,
109
+ )
110
+
111
+ if args.json:
112
+ picked = _trim_to_tokens(files, args.max_tokens) if args.max_tokens is not None else files
113
+ sys.stdout.write(_summary_json(root, picked) + "\n")
114
+ return 0
115
+
116
+ if args.max_tokens is not None:
117
+ files = _trim_to_tokens(files, args.max_tokens)
118
+ if not files:
119
+ print("ctx: nothing to pack", file=sys.stderr)
120
+ return 1
121
+
122
+ doc = render_document(root.name or "repo", files, include_tree=not args.no_tree)
123
+ return _emit(doc, output_path)
124
+
125
+
126
+ def _emit(doc: str, output: Path | None) -> int:
127
+ if output is None:
128
+ sys.stdout.write(doc + "\n")
129
+ return 0
130
+ try:
131
+ output.write_text(doc, encoding="utf-8")
132
+ except OSError as exc:
133
+ print(f"ctx: cannot write {output}: {exc}", file=sys.stderr)
134
+ return 1
135
+ return 0
136
+
137
+
138
+ def _trim_to_tokens(files, limit: int) -> list:
139
+ """Keep files while the estimated output stays under ``limit``.
140
+
141
+ The estimate covers the emitted text a file actually produces: its body
142
+ tokens plus the fixed per-section overhead (heading, blank lines, fences).
143
+ Binary files cost only the overhead and are always kept, so they stay
144
+ listed with their omission note. The first file always ships even if it
145
+ alone exceeds ``limit`` — packing nothing because one file is big helps
146
+ nobody.
147
+ """
148
+ kept: list = []
149
+ body_tokens = 0
150
+ for info in files:
151
+ cost = _PER_FILE_OVERHEAD
152
+ if not info.binary:
153
+ text = _read(info)
154
+ if not text:
155
+ continue
156
+ cost += estimate_tokens(text)
157
+ if body_tokens + cost > limit:
158
+ # Even the next file alone exceeds the remaining budget, so no
159
+ # further file can fit; what fits so far is kept (possibly nothing,
160
+ # when a single big file alone exceeds the whole limit).
161
+ break
162
+ kept.append(info)
163
+ body_tokens += cost
164
+ return kept
165
+
166
+
167
+ def _summary_json(root: Path, files) -> str:
168
+ total_bytes = sum(f.size for f in files)
169
+ tokens = sum(_PER_FILE_OVERHEAD + estimate_tokens(_read(f)) for f in files if not f.binary)
170
+ payload = {
171
+ "root": str(root),
172
+ "files": [{"path": f.rel, "bytes": f.size, "binary": f.binary} for f in files],
173
+ "count": len(files),
174
+ "bytes": total_bytes,
175
+ "tokens": tokens,
176
+ }
177
+ return json.dumps(payload, indent=2)
178
+
179
+
180
+ def _read(info) -> str:
181
+ try:
182
+ text = info.path.read_text(encoding="utf-8")
183
+ except (UnicodeDecodeError, OSError):
184
+ return ""
185
+ return "" if is_nul_in_text(text) else text
186
+
187
+
188
+ if __name__ == "__main__":
189
+ raise SystemExit(main())
ctx/ignore.py ADDED
@@ -0,0 +1,74 @@
1
+ """Gitignore-aware path filtering built on ``pathspec``.
2
+
3
+ A :class:`IgnoreMatcher` loads ``.gitignore`` (and optionally a private
4
+ ``.ctxignore``) files from the tree it is asked about, keyed by their directory,
5
+ so nested files follow git's semantics: patterns are relative to the file that
6
+ carries them, and a deeper file's decision wins over a shallower one's.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ from pathspec import PathSpec
14
+ from pathspec.patterns.gitwildmatch import GitIgnoreSpecPattern
15
+
16
+
17
+ class IgnoreMatcher:
18
+ """Matches repository paths against accumulated ignore rules."""
19
+
20
+ def __init__(self, root: Path) -> None:
21
+ self.root = Path(root)
22
+ self._dirs: dict[str, PathSpec] = {} # os-relpath -> lazily built spec
23
+
24
+ def _spec_for(self, rel_dir: str) -> PathSpec:
25
+ spec = self._dirs.get(rel_dir)
26
+ if spec is not None:
27
+ return spec
28
+ lines: list[str] = []
29
+ base = self.root if not rel_dir else self.root / rel_dir
30
+ for name in (".gitignore", ".ctxignore"):
31
+ candidate = base / name
32
+ if candidate.is_file():
33
+ _append_lines(candidate, lines)
34
+ spec = PathSpec.from_lines(GitIgnoreSpecPattern, lines)
35
+ self._dirs[rel_dir] = spec
36
+ return spec
37
+
38
+ def check(self, rel_path: str, is_dir: bool = False) -> bool:
39
+ """True when ``rel_path`` should be excluded from the context file.
40
+
41
+ Evaluates the rule files deepest-first: the decision of a ``.gitignore``
42
+ in the path's own directory (or its nearest ancestor) wins over a higher
43
+ one, mirroring git. Later lines within one file override earlier ones
44
+ (handled by pathspec).
45
+ """
46
+ dir_chain: list[str] = []
47
+ parent = rel_path.rsplit("/", 1)[0] if "/" in rel_path else ""
48
+ while True:
49
+ dir_chain.append(parent)
50
+ if not parent:
51
+ break
52
+ parent = parent.rsplit("/", 1)[0] if "/" in parent else ""
53
+
54
+ for rule_dir in dir_chain: # deepest -> root
55
+ spec = self._spec_for(rule_dir)
56
+ candidate = rel_path[len(rule_dir) + 1 :] if rule_dir else rel_path
57
+ if is_dir and not candidate.endswith("/"):
58
+ candidate += "/"
59
+ result = spec.check_file(candidate)
60
+ if result.include is not None:
61
+ return result.include
62
+ return False
63
+
64
+
65
+ def _append_lines(file: Path, lines: list[str]) -> None:
66
+ try:
67
+ text = file.read_text(encoding="utf-8")
68
+ except (OSError, UnicodeDecodeError):
69
+ return
70
+ for line in text.splitlines():
71
+ stripped = line.strip()
72
+ if not stripped or stripped.startswith("#"):
73
+ continue
74
+ lines.append(line)
ctx/language.py ADDED
@@ -0,0 +1,80 @@
1
+ """Map file extensions to fenced-code-block language hints.
2
+
3
+ Everything falls back to ``None``, which produces an unlabelled fence. Only the
4
+ extensions people hit often are listed; adding more is trivially safe.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ _MAP = {
10
+ ".py": "python",
11
+ ".pyi": "python",
12
+ ".js": "javascript",
13
+ ".mjs": "javascript",
14
+ ".cjs": "javascript",
15
+ ".jsx": "jsx",
16
+ ".ts": "typescript",
17
+ ".tsx": "tsx",
18
+ ".rs": "rust",
19
+ ".go": "go",
20
+ ".java": "java",
21
+ ".c": "c",
22
+ ".h": "c",
23
+ ".cpp": "cpp",
24
+ ".cc": "cpp",
25
+ ".hpp": "cpp",
26
+ ".cs": "csharp",
27
+ ".rb": "ruby",
28
+ ".php": "php",
29
+ ".swift": "swift",
30
+ ".kt": "kotlin",
31
+ ".scala": "scala",
32
+ ".sh": "bash",
33
+ ".bash": "bash",
34
+ ".zsh": "bash",
35
+ ".fish": "fish",
36
+ ".ps1": "powershell",
37
+ ".sql": "sql",
38
+ ".html": "html",
39
+ ".htm": "html",
40
+ ".css": "css",
41
+ ".scss": "scss",
42
+ ".less": "less",
43
+ ".json": "json",
44
+ ".jsonc": "json",
45
+ ".yaml": "yaml",
46
+ ".yml": "yaml",
47
+ ".toml": "toml",
48
+ ".xml": "xml",
49
+ ".md": "markdown",
50
+ ".markdown": "markdown",
51
+ ".rst": "rst",
52
+ ".tex": "latex",
53
+ ".lua": "lua",
54
+ ".pl": "perl",
55
+ ".ex": "elixir",
56
+ ".erl": "erlang",
57
+ ".hs": "haskell",
58
+ ".ml": "ocaml",
59
+ ".clj": "clojure",
60
+ ".zig": "zig",
61
+ ".dart": "dart",
62
+ ".sol": "solidity",
63
+ ".dockerfile": "dockerfile",
64
+ ".docker": "dockerfile",
65
+ ".tf": "hcl",
66
+ ".hcl": "hcl",
67
+ }
68
+
69
+
70
+ def language_for(relative_path: str) -> str | None:
71
+ """Return a language hint for ``relative_path``, or None."""
72
+ for suffix, hint in _MAP.items():
73
+ if relative_path.lower().endswith(suffix):
74
+ return hint
75
+ lower = relative_path.lower()
76
+ if lower in {"makefile", "dockerfile"}:
77
+ return lower
78
+ if lower in {"gemfile", "rakefile"}:
79
+ return "ruby"
80
+ return None
ctx/py.typed ADDED
File without changes
ctx/render.py ADDED
@@ -0,0 +1,110 @@
1
+ """Render the tailored context file: an ASCII tree plus per-file sections."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ctx.binary import is_nul_in_text
6
+ from ctx.language import language_for
7
+ from ctx.tokens import estimate_tokens
8
+ from ctx.walker import FileInfo
9
+
10
+ _BOX = {"pipe": "│ ", "mid": "├── ", "last": "└── ", "pad": " "}
11
+
12
+
13
+ def build_tree(rels: list[str]) -> str:
14
+ """Return an ASCII directory tree for the given POSIX relative paths."""
15
+ root: dict = {}
16
+ for rel in rels:
17
+ node = root
18
+ for part in rel.split("/"):
19
+ node = node.setdefault(part, {})
20
+ lines: list[str] = []
21
+
22
+ def walk(node: dict, prefix: str) -> None:
23
+ # Directories (non-empty children dicts) first, then files, each
24
+ # case-insensitively.
25
+ items = sorted(node.items(), key=lambda kv: (not bool(kv[1]), kv[0].lower()))
26
+ for i, (name, child) in enumerate(items):
27
+ is_last = i == len(items) - 1
28
+ stem = prefix + (_BOX["last"] if is_last else _BOX["mid"]) + name
29
+ if child:
30
+ lines.append(stem + "/")
31
+ extension = _BOX["pad"] if is_last else _BOX["pipe"]
32
+ walk(child, prefix + extension)
33
+ else:
34
+ lines.append(stem)
35
+
36
+ walk(root, "")
37
+ return "\n".join(lines) or "."
38
+
39
+
40
+ def _fence_for(body: str) -> str:
41
+ """A code fence delimiter that ``body``'s backtick runs cannot break.
42
+
43
+ Uses one backtick more than the longest run in the body (min 3, max 8 — a
44
+ run that long is beyond any reasonable source file, and capping avoids
45
+ pathological fence sizes). ``body`` may instead be omitted with a note.
46
+ """
47
+ longest = 0
48
+ run = 0
49
+ for char in body:
50
+ if char == "`":
51
+ run += 1
52
+ longest = max(longest, run)
53
+ else:
54
+ run = 0
55
+ return "`" * min(max(longest + 1, 3), 8)
56
+
57
+
58
+ def render_document(
59
+ root_name: str,
60
+ files: list[FileInfo],
61
+ *,
62
+ include_tree: bool = True,
63
+ ) -> str:
64
+ """Build the whole markdown document as a string."""
65
+ total_bytes = sum(f.size for f in files)
66
+
67
+ # Read file bodies once, so binary/undecodable files are handled in a single
68
+ # pass and the token estimate matches exactly what gets emitted.
69
+ bodies: dict[str, str] = {}
70
+ skipped: dict[str, str] = {}
71
+ text_parts: list[str] = []
72
+ for info in files:
73
+ if info.binary:
74
+ skipped[info.rel] = "binary — content omitted"
75
+ continue
76
+ try:
77
+ text = info.path.read_text(encoding="utf-8")
78
+ except (UnicodeDecodeError, OSError):
79
+ skipped[info.rel] = "binary — content omitted"
80
+ continue
81
+ if is_nul_in_text(text):
82
+ skipped[info.rel] = "binary — content omitted"
83
+ continue
84
+ bodies[info.rel] = text
85
+ text_parts.append(text)
86
+ token_estimate = estimate_tokens("\n".join(text_parts))
87
+
88
+ out: list[str] = [
89
+ f"# {root_name} — context",
90
+ "",
91
+ f"- files: **{len(files)}** · **{total_bytes} bytes** · ~**{token_estimate} tokens**",
92
+ "",
93
+ ]
94
+ if include_tree:
95
+ out += ["## Tree", "", "```text", build_tree([f.rel for f in files]), "```", ""]
96
+
97
+ out += ["## Files", ""]
98
+ for info in files:
99
+ out.append(f"### `{info.rel}`")
100
+ out.append("")
101
+ if info.rel in skipped:
102
+ out.append(f"> {skipped[info.rel]}")
103
+ out.append("")
104
+ continue
105
+ fence = _fence_for(bodies[info.rel])
106
+ out.append(f"{fence}{language_for(info.rel) or ''}")
107
+ out.append(bodies[info.rel].rstrip("\n"))
108
+ out.append(fence)
109
+ out.append("")
110
+ return "\n".join(out)
ctx/tokens.py ADDED
@@ -0,0 +1,29 @@
1
+ """Rough token estimation without any tokenizer dependency.
2
+
3
+ ``ctx`` aims to be dependency-light and offline. A real BPE tokenizer (tiktoken)
4
+ is an extra dependency most users will not want, so this module approximates
5
+ tokens from a mix of characters and whitespace-delimited words. It is calibrated
6
+ toward English prose and code and overestimates by ~10-20% rather than
7
+ underestimating, which is the safe direction for context budgeting.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+
14
+
15
+ def estimate_tokens(text: str) -> int:
16
+ """Estimate the number of tokens ``text`` contains.
17
+
18
+ Uses a blended heuristic: about one token per 4.0 characters, nudged upward
19
+ by whitespace-delimited words. The result is an integer >= 1.
20
+ """
21
+ if not text:
22
+ return 0
23
+ chars = len(text)
24
+ words = len(text.split())
25
+ # Code mixes in long identifiers and symbols; prose is denser per word. The
26
+ # 4.0 chars/token base handles long tokens, and 1.1 words/token proxy keeps
27
+ # short-word prose honest.
28
+ estimate = chars / 4.0 + words * 0.4
29
+ return max(1, math.ceil(estimate))
ctx/walker.py ADDED
@@ -0,0 +1,128 @@
1
+ """Repository file discovery: walk, filter, and produce FileInfo records."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from pathspec import PathSpec
10
+ from pathspec.patterns.gitwildmatch import GitIgnoreSpecPattern
11
+
12
+ from ctx.binary import is_binary
13
+ from ctx.ignore import IgnoreMatcher
14
+
15
+ # Always skipped, even with --no-ignore.
16
+ _EXCLUDED_DIRS = {
17
+ ".git",
18
+ ".svn",
19
+ ".hg",
20
+ ".ctx",
21
+ ".pytest_cache",
22
+ ".ruff_cache",
23
+ ".mypy_cache",
24
+ "__pycache__",
25
+ }
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class FileInfo:
30
+ """One file that made it into the context file."""
31
+
32
+ rel: str # POSIX-ish path relative to the repo root
33
+ path: Path # absolute path on disk
34
+ size: int # bytes
35
+ binary: bool # detected as binary (content excluded from output)
36
+
37
+
38
+ def discover(
39
+ root: Path,
40
+ *,
41
+ include: tuple[str, ...] = (),
42
+ exclude: tuple[str, ...] = (),
43
+ max_size: int | None = None,
44
+ skip_binary: bool = True,
45
+ ) -> list[FileInfo]:
46
+ """Walk ``root`` and return the files that belong in the context file.
47
+
48
+ Honors ``.gitignore`` everywhere in the tree. ``include`` and ``exclude``
49
+ are pathspec-style patterns applied to POSIX relative paths after ignore
50
+ filtering; ``exclude`` wins over ``include``. Files larger than ``max_size``
51
+ bytes are dropped. ``skip_binary`` keeps binary files in the list (so the
52
+ reader knows they were omitted); when false, binary content is read anyway.
53
+ """
54
+ root = Path(root).resolve()
55
+ ignore = IgnoreMatcher(root)
56
+ include_spec = PathSpec.from_lines(GitIgnoreSpecPattern, include) if include else None
57
+ exclude_spec = PathSpec.from_lines(GitIgnoreSpecPattern, exclude) if exclude else None
58
+
59
+ files: list[FileInfo] = []
60
+ for dirpath, dirnames, filenames in _walk(root, ignore):
61
+ # Prune ignored directories in place so we never descend into them.
62
+ kept: list[str] = []
63
+ for name in dirnames:
64
+ rel = _rel(root, dirpath, name)
65
+ if name in _EXCLUDED_DIRS or ignore.check(rel, is_dir=True):
66
+ continue
67
+ kept.append(name)
68
+ dirnames[:] = kept
69
+
70
+ for name in filenames:
71
+ abs_path = Path(dirpath) / name
72
+ rel = _rel(root, dirpath, name)
73
+ if _blocked(rel, include_spec, exclude_spec):
74
+ continue
75
+ if ignore.check(rel, is_dir=False):
76
+ continue
77
+ try:
78
+ size = abs_path.stat().st_size
79
+ except OSError:
80
+ continue
81
+ if max_size is not None and size > max_size:
82
+ continue
83
+ binary = is_binary(abs_path) if skip_binary else False
84
+ files.append(FileInfo(rel=rel, path=abs_path, size=size, binary=binary))
85
+
86
+ files.sort(key=lambda f: (f.rel.lower(), f.rel))
87
+ return files
88
+
89
+
90
+ def _walk(root: Path, ignore: IgnoreMatcher):
91
+ """os.walk-compatible generator; ``ignore`` is consulted by ``discover``."""
92
+ stack = [str(root)]
93
+ while stack:
94
+ dirpath = stack.pop()
95
+ try:
96
+ with os.scandir(dirpath) as it:
97
+ names = sorted((e.name for e in it), key=str.lower)
98
+ except OSError:
99
+ continue
100
+ dirnames: list[str] = []
101
+ filenames: list[str] = []
102
+ for name in names:
103
+ child = os.path.join(dirpath, name)
104
+ try:
105
+ if os.path.isdir(child) and not os.path.islink(child):
106
+ dirnames.append(name)
107
+ elif os.path.isfile(child):
108
+ filenames.append(name)
109
+ except OSError:
110
+ continue
111
+ yield dirpath, dirnames, filenames
112
+ for name in reversed(dirnames):
113
+ stack.append(os.path.join(dirpath, name))
114
+
115
+
116
+ def _rel(root: Path, dirpath: str, name: str) -> str:
117
+ if dirpath == str(root):
118
+ return name
119
+ relative = str(Path(dirpath).relative_to(root))
120
+ if os.sep == "\\": # Windows paths are backslash-separated; normalise to /
121
+ relative = relative.replace("\\", "/")
122
+ return relative + "/" + name
123
+
124
+
125
+ def _blocked(rel: str, include: PathSpec | None, exclude: PathSpec | None) -> bool:
126
+ if exclude is not None and exclude.match_file(rel):
127
+ return True
128
+ return include is not None and not include.match_file(rel)
@@ -0,0 +1,218 @@
1
+ Metadata-Version: 2.4
2
+ Name: repocxt
3
+ Version: 0.1.0
4
+ Summary: Package a repository into a single, LLM-ready context file with a tree, file contents, and a token estimate.
5
+ Author: Kamran
6
+ License-Expression: MIT
7
+ Keywords: llm,context,cli,code,ai,prompt
8
+ Classifier: Environment :: Console
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Software Development :: Libraries
16
+ Classifier: Topic :: Software Development :: Build Tools
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: pathspec>=1.1
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=7.4; extra == "dev"
23
+ Requires-Dist: pytest-cov>=4.1; extra == "dev"
24
+ Requires-Dist: ruff>=0.4; extra == "dev"
25
+ Provides-Extra: build
26
+ Requires-Dist: build>=1.0; extra == "build"
27
+ Requires-Dist: twine>=4.0; extra == "build"
28
+ Dynamic: license-file
29
+
30
+ # ctx
31
+
32
+ Package a repository into a single, LLM-ready context file.
33
+
34
+ `ctx` walks a directory and emits one markdown file containing an ASCII tree of
35
+ the repo, the contents of every source file (fenced with language hints), and a
36
+ token estimate — so you can paste a whole repository into an LLM prompt, or
37
+ point an agent at it, and the model sees structure and code together.
38
+
39
+ ## What it looks like
40
+
41
+ Running `ctx` on this repo (tree trimmed for the README; the header counts are real):
42
+
43
+ ````text
44
+ # repo — context
45
+
46
+ - files: **26** · **67763 bytes** · ~**19876 tokens**
47
+
48
+ ## Tree
49
+
50
+ ```text
51
+ ├── .github/
52
+ │ ├── workflows/
53
+ │ │ ├── ci.yml
54
+ │ │ └── release.yml
55
+ │ └── FUNDING.yml
56
+ ├── src/
57
+ │ └── ctx/
58
+ │ ├── __init__.py
59
+ │ ├── __main__.py
60
+ │ ├── ...
61
+ │ └── walker.py
62
+ ├── tests/
63
+ │ ├── conftest.py
64
+ │ ├── test_cli.py
65
+ │ ├── test_ignore.py
66
+ │ ├── test_render.py
67
+ │ ├── test_tokens.py
68
+ │ └── test_walker.py
69
+ ├── .gitignore
70
+ ├── CHANGELOG.md
71
+ ├── CONTRIBUTING.md
72
+ ├── LICENSE
73
+ ├── MANIFEST.in
74
+ ├── pyproject.toml
75
+ └── README.md
76
+ ```
77
+ ````
78
+
79
+ ## Features
80
+
81
+ - **One file out.** Directory tree, file contents, and a token estimate in a
82
+ single markdown document — ready to paste into a prompt.
83
+ - **Gitignore-aware.** Follows `.gitignore` (and `.ctxignore`) at every level
84
+ of the tree, with git's semantics: deeper rule files win.
85
+ - **Binary-safe.** Binary files are detected and listed, but their content is
86
+ omitted instead of corrupting the document.
87
+ - **Token-aware.** `--max-tokens` stops early once the running estimate crosses
88
+ the limit, so the output fits your context window.
89
+ - **Filterable.** `--include` / `--exclude` with repeatable pathspec globs, and
90
+ `--max-size` to skip files that are too big.
91
+ - **Offline and light.** The only runtime dependency is `pathspec`.
92
+
93
+ ## Install
94
+
95
+ Requires Python 3.10+. The package is published on PyPI as `repocxt`; the
96
+ command you run is `ctx`.
97
+
98
+ ```bash
99
+ pipx install repocxt
100
+ ```
101
+
102
+ Or install from a checkout directly:
103
+
104
+ ```bash
105
+ pipx install .
106
+ ```
107
+
108
+ Or with plain pip:
109
+
110
+ ```bash
111
+ pip install repocxt
112
+ ```
113
+
114
+ However you install it, you get a `ctx` command (it also runs as
115
+ `python -m ctx`).
116
+
117
+ ## Usage
118
+
119
+ ```text
120
+ usage: ctx [-h] [-o FILE] [--include GLOB] [--exclude GLOB] [--max-size BYTES]
121
+ [--max-tokens N] [--no-tree] [--json] [--version]
122
+ [root]
123
+ ```
124
+
125
+ - `root` — directory to pack. Defaults to the current directory. The header
126
+ uses the directory's name (or `repo` when the name is empty, e.g. `.`).
127
+ - `-o FILE, --output FILE` — write to a file instead of stdout. `-o -` means
128
+ stdout explicitly.
129
+ - `--include GLOB` / `--exclude GLOB` — keep or drop paths matching a glob
130
+ (repeatable; pathspec syntax). Both are applied after ignore filtering, and
131
+ `--exclude` wins over `--include`.
132
+ - `--max-size BYTES` — skip files larger than this many bytes.
133
+ - `--max-tokens N` — stop once the emitted text reaches N tokens.
134
+ - `--no-tree` — omit the ASCII tree section; just the files.
135
+ - `--json` — print a machine-readable summary (file list, counts, bytes,
136
+ tokens) instead of the document.
137
+
138
+ ### With LLMs
139
+
140
+ The output is self-contained, so driving an LLM is two steps:
141
+
142
+ ```bash
143
+ # write it to a file for an agent that can read files
144
+ ctx -o ctx.md
145
+
146
+ # or just paste stdout straight into the prompt
147
+ ctx | head -c 20000
148
+ ```
149
+
150
+ Point the model at `ctx.md` ("this is my whole repo, read it") or paste it
151
+ directly — the tree tells it the layout, the fenced blocks give it the code,
152
+ and the token estimate tells you whether it fits.
153
+
154
+ ## How it decides what to include
155
+
156
+ - **Gitignore semantics.** `.gitignore` files are read at every directory and
157
+ applied to relative paths; a deeper `.gitignore`'s decision wins over a
158
+ shallower one's, and later lines override earlier ones. A private
159
+ `.ctxignore` file is honored the same way, for rules you do not want to
160
+ commit to git.
161
+ - **Always-skipped directories.** `.git`, `.svn`, `.hg`, `.ctx`,
162
+ `__pycache__`, and `.mypy_cache` are always skipped.
163
+ - **Binary omission.** A file is treated as binary when its first 8 KB contain
164
+ a NUL byte — images, archives, compiled objects. It is still listed in the
165
+ document, but with a note instead of content.
166
+ - **Files, not directories.** Only regular files are packed; empty directories
167
+ and directory symlinks do not appear in the tree.
168
+
169
+ ## Philosophy
170
+
171
+ Offline-first. There is no telemetry, no network call, and no tokenizer — token
172
+ counts come from a small local heuristic that errs high, which is the safe
173
+ direction for context budgeting. The dependency list stays minimal
174
+ (`pathspec` is the only runtime dependency) and the output is plain markdown
175
+ you can read, diff, and paste anywhere.
176
+
177
+ ## Development
178
+
179
+ ```bash
180
+ git clone <your fork>/ctx
181
+ cd ctx
182
+ python3 -m venv .venv && source .venv/bin/activate
183
+ pip install -e ".[dev]"
184
+ ```
185
+
186
+ - Run tests: `python -m pytest`
187
+ - Lint: `python -m ruff check .` (config and dev extras live in `pyproject.toml`)
188
+
189
+ No need to install to poke at the CLI:
190
+
191
+ ```bash
192
+ export PYTHONPATH="$PWD/src"
193
+ python3 -m ctx --help
194
+ ```
195
+
196
+ ## Testing & compatibility
197
+
198
+ Honest status of what is and isn't verified:
199
+
200
+ - **Tested on:** Linux, Python 3.12 — 106 automated tests pass (`pytest`),
201
+ `ruff` clean, wheel + sdist build clean, `twine check` passes. Edge cases
202
+ covered by tests: nested/overriding `.gitignore`, negation, directory-only
203
+ patterns, filenames with spaces, undecodable and binary files, oversized
204
+ files, symlinks (not followed for dirs), backtick-fences inside file bodies,
205
+ and `--max-tokens` / `--json` behavior.
206
+ - **Expected but not yet verified:** macOS and Windows, and Python 3.10–3.11.
207
+ The code is pure Python + `pathspec` with no OS-specific calls, so it
208
+ *should* run anywhere, but only Linux/3.12 has been exercised. The GitHub
209
+ Actions matrix (`ci.yml`) runs 3.10–3.12 on Linux for every push; wider
210
+ platform testing is welcome via PRs / CI runs.
211
+ - **Not verified at all:** any real PyPI publication (needs a maintainer token).
212
+
213
+ If you hit a platform you use that isn't green, open an issue with your OS,
214
+ Python version, and the error — that's the fastest way this list gets shorter.
215
+
216
+ ## License
217
+
218
+ [MIT](LICENSE) © 2026 Kamran
@@ -0,0 +1,16 @@
1
+ ctx/__init__.py,sha256=SMu8zJGEznfocmgMKukOqgwp-zMl4rvvPxyDmLQB_sw,252
2
+ ctx/__main__.py,sha256=PiakOg9Rd2nCV9Dk4hZolSr2kIpVPytnBSp58wo8y1M,114
3
+ ctx/binary.py,sha256=Ndn0X6notMTJqgqsOsCevXnIy6JxOB9pBAktSLp8FS0,1196
4
+ ctx/cli.py,sha256=C2LmGUQB4eXpFvxXXMx9G-gqxeHpKOEmMx4-Qp17ut8,5739
5
+ ctx/ignore.py,sha256=RHR9tAiqBbkIPHRjYC7kvu_SNCA3iiW41WhM4AAZMwI,2766
6
+ ctx/language.py,sha256=kIbS2u6HuhanNDCL7fMCIm2ZbOZS8fvGCU4fgiDQ30Q,1879
7
+ ctx/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ ctx/render.py,sha256=HFZGuXW5EmIi5dQ5E8M3JisrVG9bl8jgCnf8yhCruII,3700
9
+ ctx/tokens.py,sha256=TdaU8vUy25jlBgS0IKsGclycpkEjZcOsI_fP_c5jO3k,1101
10
+ ctx/walker.py,sha256=ISJ7p-iug3LwTfD22-bzHjUrO0X0bL4LIoTZHOfq9OA,4329
11
+ repocxt-0.1.0.dist-info/licenses/LICENSE,sha256=oLP7UmahGD7LTGOVoAYeTTCayBzf3h9LVQZdWwfGF8g,1063
12
+ repocxt-0.1.0.dist-info/METADATA,sha256=uvaIgOOa90vo6iPUP5rL60t_mEtIN-aFsCNlfaDrKyQ,7439
13
+ repocxt-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
14
+ repocxt-0.1.0.dist-info/entry_points.txt,sha256=qw347dSB47D2ytO2k5c-BWRiHWoT7kUvoOIWUnwAMOQ,37
15
+ repocxt-0.1.0.dist-info/top_level.txt,sha256=4GAlu6d1lfmUOY-dagUnOBgM1aKhmsrctDhmv-jn-TY,4
16
+ repocxt-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ctx = ctx.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kamran
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.
@@ -0,0 +1 @@
1
+ ctx