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 +8 -0
- ctx/__main__.py +6 -0
- ctx/binary.py +37 -0
- ctx/cli.py +189 -0
- ctx/ignore.py +74 -0
- ctx/language.py +80 -0
- ctx/py.typed +0 -0
- ctx/render.py +110 -0
- ctx/tokens.py +29 -0
- ctx/walker.py +128 -0
- repocxt-0.1.0.dist-info/METADATA +218 -0
- repocxt-0.1.0.dist-info/RECORD +16 -0
- repocxt-0.1.0.dist-info/WHEEL +5 -0
- repocxt-0.1.0.dist-info/entry_points.txt +2 -0
- repocxt-0.1.0.dist-info/licenses/LICENSE +21 -0
- repocxt-0.1.0.dist-info/top_level.txt +1 -0
ctx/__init__.py
ADDED
ctx/__main__.py
ADDED
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,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
|