ctxpack-cli 1.0.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.
ctxpack.py
ADDED
|
@@ -0,0 +1,1037 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
ctxpack - Dependency-free repo-to-prompt pack builder.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
# Postponed annotation evaluation: the signatures below use `str | None` and
|
|
7
|
+
# builtin generics, which are 3.10+ syntax when evaluated eagerly. This keeps
|
|
8
|
+
# the module importable on Python 3.9, which pyproject.toml and the CI matrix
|
|
9
|
+
# both claim to support.
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
__version__ = "1.0.0"
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import codecs
|
|
16
|
+
import fnmatch
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
DEFAULT_BUDGET_TOKENS = 8000
|
|
24
|
+
DEFAULT_IGNORE_FILE = ".ctxignore"
|
|
25
|
+
DEFAULT_CONFIG_FILE = "ctxpack.json"
|
|
26
|
+
DEFAULT_BASE_NAME = "ctxpack"
|
|
27
|
+
DEFAULT_OUTPUT_DIR = "."
|
|
28
|
+
|
|
29
|
+
BINARY_EXTENSIONS = {
|
|
30
|
+
".png",
|
|
31
|
+
".jpg",
|
|
32
|
+
".jpeg",
|
|
33
|
+
".gif",
|
|
34
|
+
".ico",
|
|
35
|
+
".pdf",
|
|
36
|
+
".exe",
|
|
37
|
+
".dll",
|
|
38
|
+
".so",
|
|
39
|
+
".dylib",
|
|
40
|
+
".zip",
|
|
41
|
+
".tar",
|
|
42
|
+
".gz",
|
|
43
|
+
".7z",
|
|
44
|
+
".pyc",
|
|
45
|
+
".class",
|
|
46
|
+
".o",
|
|
47
|
+
".obj",
|
|
48
|
+
".bin",
|
|
49
|
+
".woff",
|
|
50
|
+
".woff2",
|
|
51
|
+
".ttf",
|
|
52
|
+
".eot",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
MAX_FILE_BYTES = 500_000 # skip files larger than ~500KB by default
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_config(root: Path) -> dict:
|
|
59
|
+
"""Load config from ctxpack.json if it exists."""
|
|
60
|
+
config_path = root / DEFAULT_CONFIG_FILE
|
|
61
|
+
if config_path.exists():
|
|
62
|
+
try:
|
|
63
|
+
with config_path.open("r", encoding="utf-8") as f:
|
|
64
|
+
return json.load(f)
|
|
65
|
+
except json.JSONDecodeError as e:
|
|
66
|
+
print(
|
|
67
|
+
f"ctxpack: warning: could not parse {DEFAULT_CONFIG_FILE}: {e}; "
|
|
68
|
+
"falling back to defaults",
|
|
69
|
+
file=sys.stderr,
|
|
70
|
+
)
|
|
71
|
+
except OSError as e:
|
|
72
|
+
print(
|
|
73
|
+
f"ctxpack: warning: could not read {DEFAULT_CONFIG_FILE}: {e}; "
|
|
74
|
+
"falling back to defaults",
|
|
75
|
+
file=sys.stderr,
|
|
76
|
+
)
|
|
77
|
+
return {}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def load_ignore_patterns(
|
|
81
|
+
root: Path, cli_exclude: list[str], strict_secrets: bool = False
|
|
82
|
+
) -> list[str]:
|
|
83
|
+
"""Load patterns from .ctxignore, merged with CLI excludes.
|
|
84
|
+
|
|
85
|
+
Secret-safe defaults: local env files, private keys, and certificate
|
|
86
|
+
bundles are excluded by default so credentials never reach the pack.
|
|
87
|
+
``!.env.example`` preserves the conventional template name.
|
|
88
|
+
|
|
89
|
+
With ``strict_secrets=True``, the secret defaults are enforced as a hard
|
|
90
|
+
block that ``.ctxignore`` / CLI negation cannot override (the built-in
|
|
91
|
+
``!.env.example`` template carve-out still applies).
|
|
92
|
+
"""
|
|
93
|
+
default_patterns = [
|
|
94
|
+
".git/**",
|
|
95
|
+
".svn/**",
|
|
96
|
+
".hg/**",
|
|
97
|
+
"__pycache__/**",
|
|
98
|
+
"*.pyc",
|
|
99
|
+
"*.pyo",
|
|
100
|
+
"node_modules/**",
|
|
101
|
+
"venv/**",
|
|
102
|
+
".venv/**",
|
|
103
|
+
"dist/**",
|
|
104
|
+
"build/**",
|
|
105
|
+
".ruff_cache/**",
|
|
106
|
+
".pytest_cache/**",
|
|
107
|
+
".mypy_cache/**",
|
|
108
|
+
".tox/**",
|
|
109
|
+
".eggs/**",
|
|
110
|
+
"*.egg-info/**",
|
|
111
|
+
"htmlcov/**",
|
|
112
|
+
".coverage",
|
|
113
|
+
".coverage.*",
|
|
114
|
+
"*.log",
|
|
115
|
+
"*.lock",
|
|
116
|
+
"package-lock.json",
|
|
117
|
+
".DS_Store",
|
|
118
|
+
"Thumbs.db",
|
|
119
|
+
DEFAULT_CONFIG_FILE,
|
|
120
|
+
f"{DEFAULT_BASE_NAME}.context.json",
|
|
121
|
+
f"{DEFAULT_BASE_NAME}.context.md",
|
|
122
|
+
# Secret-safe defaults
|
|
123
|
+
".env",
|
|
124
|
+
".env.*",
|
|
125
|
+
"!.env.example",
|
|
126
|
+
"*.pem",
|
|
127
|
+
"*.key",
|
|
128
|
+
"*.p12",
|
|
129
|
+
"*.pfx",
|
|
130
|
+
"*.crt",
|
|
131
|
+
"*.cer",
|
|
132
|
+
"*.jks",
|
|
133
|
+
"*.keystore",
|
|
134
|
+
"*.gpg",
|
|
135
|
+
"*.asc",
|
|
136
|
+
"**/.aws/**",
|
|
137
|
+
"**/.ssh/**",
|
|
138
|
+
"**/.netrc",
|
|
139
|
+
"**/.npmrc",
|
|
140
|
+
"**/.pypirc",
|
|
141
|
+
]
|
|
142
|
+
# Secret patterns enforced as a hard block under --strict-secrets.
|
|
143
|
+
# These deliberately DUPLICATE the secret entries in default_patterns:
|
|
144
|
+
# in default mode only default_patterns applies, but in strict mode
|
|
145
|
+
# this block is appended AFTER user .ctxignore patterns so that
|
|
146
|
+
# last-match-wins makes these exclusions beat any user negation.
|
|
147
|
+
secret_patterns = [
|
|
148
|
+
".env",
|
|
149
|
+
".env.*",
|
|
150
|
+
"!.env.example",
|
|
151
|
+
"*.pem",
|
|
152
|
+
"*.key",
|
|
153
|
+
"*.p12",
|
|
154
|
+
"*.pfx",
|
|
155
|
+
"*.crt",
|
|
156
|
+
"*.cer",
|
|
157
|
+
"*.jks",
|
|
158
|
+
"*.keystore",
|
|
159
|
+
"*.gpg",
|
|
160
|
+
"*.asc",
|
|
161
|
+
"**/.aws/**",
|
|
162
|
+
"**/.ssh/**",
|
|
163
|
+
"**/.netrc",
|
|
164
|
+
"**/.npmrc",
|
|
165
|
+
"**/.pypirc",
|
|
166
|
+
]
|
|
167
|
+
ignore_file = root / DEFAULT_IGNORE_FILE
|
|
168
|
+
patterns = list(default_patterns)
|
|
169
|
+
|
|
170
|
+
if ignore_file.exists():
|
|
171
|
+
with ignore_file.open("r", encoding="utf-8") as f:
|
|
172
|
+
for line in f:
|
|
173
|
+
line = line.strip()
|
|
174
|
+
if line and not line.startswith("#"):
|
|
175
|
+
patterns.append(line)
|
|
176
|
+
|
|
177
|
+
if strict_secrets:
|
|
178
|
+
# User excludes still apply in strict mode: in default mode CLI
|
|
179
|
+
# excludes only EXTEND the defaults, so honoring them here cannot
|
|
180
|
+
# weaken the secret block -- it is appended after them and wins by
|
|
181
|
+
# last-match-wins. Dropping them (the old behavior) silently packed
|
|
182
|
+
# files the user asked to exclude, e.g. --exclude "*.log".
|
|
183
|
+
patterns.extend(cli_exclude)
|
|
184
|
+
# Append the secret block LAST so its exclusions win over any
|
|
185
|
+
# negation the user config or CLI introduced. The built-in
|
|
186
|
+
# !.env.example carve-out inside secret_patterns is re-stated after
|
|
187
|
+
# each secret pattern it could be shadowed by, keeping the template
|
|
188
|
+
# opt-in available even in strict mode.
|
|
189
|
+
for pat in secret_patterns:
|
|
190
|
+
patterns.append(pat)
|
|
191
|
+
if pat == ".env.*":
|
|
192
|
+
patterns.append("!.env.example")
|
|
193
|
+
return patterns
|
|
194
|
+
|
|
195
|
+
# CLI excludes take precedence and are appended
|
|
196
|
+
patterns.extend(cli_exclude)
|
|
197
|
+
return patterns
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _unescape_for_regex(pattern: str) -> str | None:
|
|
201
|
+
r"""Convert a ctxignore pattern directly to a regex string.
|
|
202
|
+
|
|
203
|
+
Processes backslash escapes so that ``\*`` matches literal ``*``,
|
|
204
|
+
``\?`` matches literal ``?``, etc. Then converts wildcards to regex
|
|
205
|
+
syntax.
|
|
206
|
+
|
|
207
|
+
Returns None if the pattern has no backslash escapes, signaling the
|
|
208
|
+
caller to use the faster fnmatch path.
|
|
209
|
+
"""
|
|
210
|
+
if "\\" not in pattern:
|
|
211
|
+
return None
|
|
212
|
+
regex = []
|
|
213
|
+
i = 0
|
|
214
|
+
pat = pattern.rstrip("/")
|
|
215
|
+
while i < len(pat):
|
|
216
|
+
ch = pat[i]
|
|
217
|
+
if ch == "\\" and i + 1 < len(pat):
|
|
218
|
+
next_ch = pat[i + 1]
|
|
219
|
+
if next_ch in "*?[] \\":
|
|
220
|
+
regex.append(re.escape(next_ch))
|
|
221
|
+
i += 2
|
|
222
|
+
continue
|
|
223
|
+
if ch == "*" and i + 1 < len(pat) and pat[i + 1] == "*":
|
|
224
|
+
regex.append(".*")
|
|
225
|
+
i += 2
|
|
226
|
+
elif ch == "*":
|
|
227
|
+
regex.append("[^/]*")
|
|
228
|
+
i += 1
|
|
229
|
+
elif ch == "?":
|
|
230
|
+
regex.append("[^/]")
|
|
231
|
+
i += 1
|
|
232
|
+
else:
|
|
233
|
+
regex.append(re.escape(ch))
|
|
234
|
+
i += 1
|
|
235
|
+
return "^" + "".join(regex) + "$"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def matches_pattern(rel: str, name: str, pattern: str) -> bool:
|
|
239
|
+
"""Check if a path matches a ctxignore pattern.
|
|
240
|
+
|
|
241
|
+
Supports a tested subset of gitignore semantics:
|
|
242
|
+
|
|
243
|
+
- Exact match: ``foo`` matches the path ``foo`` anywhere.
|
|
244
|
+
- Bare-wildcard: ``*.log`` matches any ``*.log`` in any directory.
|
|
245
|
+
- Anchored: a leading ``/`` anchors the pattern to the scan root.
|
|
246
|
+
``/build`` matches ``build`` at root but not ``src/build``.
|
|
247
|
+
- Directory match: ``foo/`` matches the ``foo`` directory and everything
|
|
248
|
+
inside it.
|
|
249
|
+
- Recursive glob: ``**`` spans path segments. ``**/.aws/**`` matches
|
|
250
|
+
``.aws/x`` and ``nested/.aws/x``.
|
|
251
|
+
- Negation: ``!``-prefixed patterns (handled by ``should_process``).
|
|
252
|
+
- Escape: ``\\`` escapes the next character (``\\*``, ``\\?``, ``[``,
|
|
253
|
+
``]``, space, ``\\``).
|
|
254
|
+
|
|
255
|
+
Not supported (and treated as literal characters where escaped):
|
|
256
|
+
|
|
257
|
+
- Character classes ``[...]``
|
|
258
|
+
- Trailing-whitespace significance
|
|
259
|
+
"""
|
|
260
|
+
# Anchored patterns start with / and must match at the scan root.
|
|
261
|
+
anchored = pattern.startswith("/")
|
|
262
|
+
pattern_body = pattern.lstrip("/") if anchored else pattern
|
|
263
|
+
|
|
264
|
+
pat = pattern_body.rstrip("/")
|
|
265
|
+
|
|
266
|
+
# After unescaping, escape any remaining literal brackets for fnmatch,
|
|
267
|
+
# which treats unescaped '[' as the start of a character class.
|
|
268
|
+
pat_fnmatch = pat.replace("[", r"\[").replace("]", r"\]")
|
|
269
|
+
|
|
270
|
+
# If the pattern has a backslash escape, the fnmatch path can't handle
|
|
271
|
+
# it correctly (fnmatch treats `\[` as start of character class), so
|
|
272
|
+
# fall through to the regex path immediately.
|
|
273
|
+
has_escape = "\\" in pat
|
|
274
|
+
|
|
275
|
+
if not has_escape:
|
|
276
|
+
# For anchored patterns, we must match rel from its start (no prefix).
|
|
277
|
+
if anchored:
|
|
278
|
+
if fnmatch.fnmatch(rel, pat_fnmatch):
|
|
279
|
+
return True
|
|
280
|
+
if pat_fnmatch.endswith("/**"):
|
|
281
|
+
base = pat_fnmatch[:-3]
|
|
282
|
+
if fnmatch.fnmatch(rel, base) or rel.startswith(base + "/"):
|
|
283
|
+
return True
|
|
284
|
+
if "**" in pat_fnmatch:
|
|
285
|
+
regex = _unescape_for_regex(pattern_body)
|
|
286
|
+
if regex and re.match(regex, rel):
|
|
287
|
+
return True
|
|
288
|
+
return False
|
|
289
|
+
|
|
290
|
+
# Non-anchored: original behavior.
|
|
291
|
+
# Exact match (for directories like ".git" matching pattern ".git/**")
|
|
292
|
+
if fnmatch.fnmatch(rel, pat_fnmatch):
|
|
293
|
+
return True
|
|
294
|
+
# Recursive directory match (e.g., ".git/config" matching ".git/**")
|
|
295
|
+
if fnmatch.fnmatch(rel, pat_fnmatch + "/**"):
|
|
296
|
+
return True
|
|
297
|
+
# A "dir/**" pattern must also match the directory itself, otherwise
|
|
298
|
+
# os.walk still descends into .git / node_modules / __pycache__ and only
|
|
299
|
+
# their contents get filtered.
|
|
300
|
+
if pat_fnmatch.endswith("/**"):
|
|
301
|
+
base = pat_fnmatch[:-3]
|
|
302
|
+
if fnmatch.fnmatch(rel, base) or rel.startswith(base + "/"):
|
|
303
|
+
return True
|
|
304
|
+
# Bare filename match (e.g., "*.log")
|
|
305
|
+
if fnmatch.fnmatch(name, pat_fnmatch):
|
|
306
|
+
return True
|
|
307
|
+
|
|
308
|
+
# Handle ** in the middle of paths (regex fallback)
|
|
309
|
+
# Also handles backslash-escaped patterns (when has_escape is True)
|
|
310
|
+
if has_escape or "**" in pat:
|
|
311
|
+
regex = _unescape_for_regex(pattern_body)
|
|
312
|
+
if regex is None:
|
|
313
|
+
regex = (
|
|
314
|
+
"^"
|
|
315
|
+
+ re.escape(pat).replace(r"\*\*", ".*").replace(r"\*", "[^/]*")
|
|
316
|
+
+ "$"
|
|
317
|
+
)
|
|
318
|
+
if re.match(regex, rel):
|
|
319
|
+
return True
|
|
320
|
+
# `**/` at the start must also match at the root of the tree,
|
|
321
|
+
# not just nested paths. Strip it and retry.
|
|
322
|
+
if pat.startswith("**/"):
|
|
323
|
+
stripped = pat[3:]
|
|
324
|
+
stripped_regex = _unescape_for_regex(stripped)
|
|
325
|
+
if stripped_regex is None:
|
|
326
|
+
stripped_regex = (
|
|
327
|
+
"^"
|
|
328
|
+
+ re.escape(stripped).replace(r"\*\*", ".*").replace(r"\*", "[^/]*")
|
|
329
|
+
+ "$"
|
|
330
|
+
)
|
|
331
|
+
if re.match(stripped_regex, rel):
|
|
332
|
+
return True
|
|
333
|
+
# Also match the directory itself for `**/.dir/**` patterns.
|
|
334
|
+
if stripped.endswith("/**") and (stripped_path := stripped[:-3]):
|
|
335
|
+
return fnmatch.fnmatch(rel, stripped_path)
|
|
336
|
+
return False
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def should_process(
|
|
340
|
+
path: Path, root: Path, include_patterns: list[str], exclude_patterns: list[str]
|
|
341
|
+
) -> bool:
|
|
342
|
+
"""Determine if a file should be processed based on include/exclude rules.
|
|
343
|
+
|
|
344
|
+
Negation patterns (``!``-prefixed) in ``exclude_patterns`` re-include a
|
|
345
|
+
path that an earlier pattern excluded, matching gitignore semantics where
|
|
346
|
+
the last matching pattern wins.
|
|
347
|
+
"""
|
|
348
|
+
rel = path.relative_to(root).as_posix()
|
|
349
|
+
name = path.name
|
|
350
|
+
|
|
351
|
+
# 1. Exclude takes absolute precedence, with negation support.
|
|
352
|
+
# Later negations can override earlier exclusions.
|
|
353
|
+
excluded = False
|
|
354
|
+
for pat in exclude_patterns:
|
|
355
|
+
if pat.startswith("!"):
|
|
356
|
+
if matches_pattern(rel, name, pat[1:]):
|
|
357
|
+
excluded = False
|
|
358
|
+
elif matches_pattern(rel, name, pat):
|
|
359
|
+
excluded = True
|
|
360
|
+
if excluded:
|
|
361
|
+
return False
|
|
362
|
+
|
|
363
|
+
# 2. If no include patterns, everything not excluded is included
|
|
364
|
+
if not include_patterns:
|
|
365
|
+
return True
|
|
366
|
+
|
|
367
|
+
# 3. Must match at least one include pattern
|
|
368
|
+
for pat in include_patterns:
|
|
369
|
+
if matches_pattern(rel, name, pat):
|
|
370
|
+
return True
|
|
371
|
+
|
|
372
|
+
return False
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def estimate_tokens(text: str) -> int:
|
|
376
|
+
"""Estimate token count for text content.
|
|
377
|
+
|
|
378
|
+
Uses a two-tier heuristic:
|
|
379
|
+
|
|
380
|
+
- ASCII/Latin text: ~4 characters per token, approximating typical LLM
|
|
381
|
+
tokenization for English prose and code.
|
|
382
|
+
- CJK-adjacent text: ~1.5 characters per token, since CJK scripts
|
|
383
|
+
tokenize far less densely than Latin text (typically 1-2 tokens per
|
|
384
|
+
character, not 0.25). The U+2E80-U+9FFF range intentionally covers
|
|
385
|
+
CJK Unified Ideographs plus Hiragana, Katakana, Bopomofo, CJK
|
|
386
|
+
Symbols/Punctuation, CJK Strokes, and Katakana Phonetic Extensions,
|
|
387
|
+
all of which fall inside it; Hangul (U+AC00-D7AF) and CJK
|
|
388
|
+
Compatibility Ideographs (U+F900-FAFF) have their own ranges.
|
|
389
|
+
|
|
390
|
+
Mixed text is estimated by counting each script's contribution
|
|
391
|
+
separately. This is a rough estimate intended for budgeting purposes,
|
|
392
|
+
not an exact count.
|
|
393
|
+
|
|
394
|
+
Edge cases:
|
|
395
|
+
- Empty strings return 0 tokens (no content = no tokens)
|
|
396
|
+
- Very short strings (< 4 chars) return 1 token to avoid zero estimates
|
|
397
|
+
- The truncation marker accounts for its own token cost
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
text: The text content to estimate tokens for
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
Estimated token count (minimum 1 for non-empty text, 0 for empty)
|
|
404
|
+
"""
|
|
405
|
+
if not text:
|
|
406
|
+
return 0
|
|
407
|
+
cjk_chars = sum(
|
|
408
|
+
1
|
|
409
|
+
for ch in text
|
|
410
|
+
if "\u2e80" <= ch <= "\u9fff"
|
|
411
|
+
or "\uac00" <= ch <= "\ud7af"
|
|
412
|
+
or "\uf900" <= ch <= "\ufaff"
|
|
413
|
+
)
|
|
414
|
+
if cjk_chars == len(text):
|
|
415
|
+
# Pure CJK: ~1.5 chars per token
|
|
416
|
+
return max(1, round(len(text) / 1.5))
|
|
417
|
+
if cjk_chars == 0:
|
|
418
|
+
# Pure ASCII/Latin: ~4 chars per token
|
|
419
|
+
return max(1, len(text) // 4)
|
|
420
|
+
# Mixed: estimate each script separately and sum
|
|
421
|
+
latin_len = len(text) - cjk_chars
|
|
422
|
+
return max(1, latin_len // 4 + round(cjk_chars / 1.5))
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def looks_binary_bytes(data: bytes) -> bool:
|
|
426
|
+
"""Detect binary content by inspecting bytes, not just the extension.
|
|
427
|
+
|
|
428
|
+
Extension checks alone let files like .coverage (a SQLite database),
|
|
429
|
+
.db, .sqlite, or any unknown suffix through, where errors="replace"
|
|
430
|
+
turns them into thousands of replacement characters that consume the
|
|
431
|
+
token budget and crowd out real source.
|
|
432
|
+
"""
|
|
433
|
+
if not data:
|
|
434
|
+
return False
|
|
435
|
+
if b"\x00" in data:
|
|
436
|
+
return True
|
|
437
|
+
# A high proportion of undecodable bytes means this is not text.
|
|
438
|
+
try:
|
|
439
|
+
data.decode("utf-8")
|
|
440
|
+
except UnicodeDecodeError:
|
|
441
|
+
decoded = data.decode("utf-8", errors="replace")
|
|
442
|
+
if decoded.count("\ufffd") / max(1, len(decoded)) > 0.05:
|
|
443
|
+
return True
|
|
444
|
+
return False
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def looks_binary(path: Path, probe_bytes: int = 8192) -> bool:
|
|
448
|
+
"""Path-based convenience wrapper around looks_binary_bytes()."""
|
|
449
|
+
try:
|
|
450
|
+
with path.open("rb") as f:
|
|
451
|
+
chunk = f.read(probe_bytes)
|
|
452
|
+
except OSError:
|
|
453
|
+
return True
|
|
454
|
+
return looks_binary_bytes(chunk)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _decode_with_bom(data: bytes) -> str | None:
|
|
458
|
+
"""Decode bytes honoring a UTF BOM; None if no recognized BOM.
|
|
459
|
+
|
|
460
|
+
Windows toolchains (PowerShell, Visual Studio, some CSV exporters)
|
|
461
|
+
write text as UTF-16 with a BOM. Plain utf-8 decoding of such files
|
|
462
|
+
yields ~50% U+FFFD, which looks_binary() rightly treats as binary --
|
|
463
|
+
so without this sniff those files were silently dropped from the pack
|
|
464
|
+
(issue #25). Only an explicit BOM changes the encoding decision;
|
|
465
|
+
guessing at BOM-less encodings is out of scope.
|
|
466
|
+
"""
|
|
467
|
+
if data.startswith(codecs.BOM_UTF8):
|
|
468
|
+
return data.decode("utf-8-sig", errors="replace")
|
|
469
|
+
for bom, encoding in (
|
|
470
|
+
(codecs.BOM_UTF32_LE, "utf-32-le"),
|
|
471
|
+
(codecs.BOM_UTF32_BE, "utf-32-be"),
|
|
472
|
+
(codecs.BOM_UTF16_LE, "utf-16-le"),
|
|
473
|
+
(codecs.BOM_UTF16_BE, "utf-16-be"),
|
|
474
|
+
):
|
|
475
|
+
# Check the 4-byte BOMs before the 2-byte ones so UTF-32 files are
|
|
476
|
+
# not misread as UTF-16.
|
|
477
|
+
if data.startswith(bom):
|
|
478
|
+
return data[len(bom) :].decode(encoding, errors="replace")
|
|
479
|
+
return None
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def read_text_file(path: Path) -> str | None:
|
|
483
|
+
"""Read file as text if not binary and not too large.
|
|
484
|
+
|
|
485
|
+
UTF-8 is assumed unless the file carries a UTF BOM, in which case the
|
|
486
|
+
BOM's encoding wins (issue #25).
|
|
487
|
+
"""
|
|
488
|
+
if path.suffix.lower() in BINARY_EXTENSIONS:
|
|
489
|
+
return None
|
|
490
|
+
try:
|
|
491
|
+
size = path.stat().st_size
|
|
492
|
+
if size > MAX_FILE_BYTES:
|
|
493
|
+
return f"[File skipped: too large ({size} bytes)]"
|
|
494
|
+
raw = path.read_bytes()
|
|
495
|
+
except OSError as e:
|
|
496
|
+
return f"[Error reading file: {e}]"
|
|
497
|
+
decoded = _decode_with_bom(raw[:MAX_FILE_BYTES])
|
|
498
|
+
if decoded is not None:
|
|
499
|
+
return decoded
|
|
500
|
+
if looks_binary_bytes(raw):
|
|
501
|
+
return None
|
|
502
|
+
return raw.decode("utf-8", errors="replace")
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def build_file_inventory(
|
|
506
|
+
root: Path, include_patterns: list[str], exclude_patterns: list[str]
|
|
507
|
+
) -> list[dict]:
|
|
508
|
+
"""Walk root, collect non-ignored text files with metadata."""
|
|
509
|
+
inventory = []
|
|
510
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
511
|
+
dirpath = Path(dirpath)
|
|
512
|
+
|
|
513
|
+
# Filter dirs in-place to avoid descending ignored dirs
|
|
514
|
+
valid_dirs = []
|
|
515
|
+
for d in dirnames:
|
|
516
|
+
if should_process(dirpath / d, root, include_patterns, exclude_patterns):
|
|
517
|
+
valid_dirs.append(d)
|
|
518
|
+
dirnames[:] = valid_dirs
|
|
519
|
+
|
|
520
|
+
for fname in filenames:
|
|
521
|
+
fpath = dirpath / fname
|
|
522
|
+
if not should_process(fpath, root, include_patterns, exclude_patterns):
|
|
523
|
+
continue
|
|
524
|
+
|
|
525
|
+
# Symlink safety: a file symlink inside the scan root may point
|
|
526
|
+
# anywhere on disk. Resolve it and skip anything that escapes the
|
|
527
|
+
# scan root so packs never exfiltrate files from outside it.
|
|
528
|
+
try:
|
|
529
|
+
if not fpath.resolve().is_relative_to(root.resolve()):
|
|
530
|
+
continue
|
|
531
|
+
except OSError:
|
|
532
|
+
continue
|
|
533
|
+
|
|
534
|
+
content = read_text_file(fpath)
|
|
535
|
+
if content is None:
|
|
536
|
+
continue
|
|
537
|
+
|
|
538
|
+
rel = fpath.relative_to(root).as_posix()
|
|
539
|
+
inventory.append(
|
|
540
|
+
{
|
|
541
|
+
"path": rel,
|
|
542
|
+
"size_bytes": fpath.stat().st_size,
|
|
543
|
+
"tokens_estimate": estimate_tokens(content),
|
|
544
|
+
"content": content,
|
|
545
|
+
}
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
inventory.sort(key=lambda x: x["path"])
|
|
549
|
+
return inventory
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
TRUNCATION_MARKER = "\n\n...[TRUNCATED by ctxpack to fit budget]..."
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _slice_to_token_prefix(content: str, max_tokens: int) -> str:
|
|
556
|
+
"""Return the longest prefix of content costing <= max_tokens.
|
|
557
|
+
|
|
558
|
+
estimate_tokens() is monotonic in prefix length, so a binary search
|
|
559
|
+
finds the exact boundary. Slicing by a fixed chars-per-token factor
|
|
560
|
+
(e.g. 4) overshoots for CJK content, which is billed at ~1.5
|
|
561
|
+
chars/token -- up to ~2.7x the intended budget (issue #20).
|
|
562
|
+
"""
|
|
563
|
+
if max_tokens < 0:
|
|
564
|
+
return ""
|
|
565
|
+
lo, hi = 0, len(content)
|
|
566
|
+
while lo < hi:
|
|
567
|
+
mid = (lo + hi + 1) // 2
|
|
568
|
+
if estimate_tokens(content[:mid]) <= max_tokens:
|
|
569
|
+
lo = mid
|
|
570
|
+
else:
|
|
571
|
+
hi = mid - 1
|
|
572
|
+
return content[:lo]
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def trim_to_budget(
|
|
576
|
+
inventory: list[dict], budget_tokens: int
|
|
577
|
+
) -> tuple[list[dict], bool]:
|
|
578
|
+
"""Truncate file contents to fit token budget.
|
|
579
|
+
|
|
580
|
+
Returns (trimmed_inventory, is_incomplete).
|
|
581
|
+
|
|
582
|
+
Every input file appears in the result so the pack is never silently
|
|
583
|
+
missing paths: files past the budget are recorded with empty content and
|
|
584
|
+
omitted=True. The truncation marker is accounted for BEFORE slicing, so
|
|
585
|
+
the emitted total stays within budget rather than overshooting by the
|
|
586
|
+
length of the marker.
|
|
587
|
+
"""
|
|
588
|
+
total_original_tokens = sum(item["tokens_estimate"] for item in inventory)
|
|
589
|
+
is_incomplete = total_original_tokens > budget_tokens
|
|
590
|
+
|
|
591
|
+
marker_tokens = estimate_tokens(TRUNCATION_MARKER)
|
|
592
|
+
total = 0
|
|
593
|
+
result = []
|
|
594
|
+
budget_spent = False
|
|
595
|
+
|
|
596
|
+
for item in inventory:
|
|
597
|
+
if budget_spent:
|
|
598
|
+
omitted = dict(item)
|
|
599
|
+
omitted["tokens_estimate_original"] = item["tokens_estimate"]
|
|
600
|
+
omitted["content"] = ""
|
|
601
|
+
omitted["tokens_estimate"] = 0
|
|
602
|
+
omitted["omitted"] = True
|
|
603
|
+
result.append(omitted)
|
|
604
|
+
continue
|
|
605
|
+
|
|
606
|
+
file_tokens = item["tokens_estimate"]
|
|
607
|
+
if total + file_tokens <= budget_tokens:
|
|
608
|
+
result.append(item)
|
|
609
|
+
total += file_tokens
|
|
610
|
+
continue
|
|
611
|
+
|
|
612
|
+
# Reserve room for the marker so the result does not exceed budget.
|
|
613
|
+
remaining = budget_tokens - total - marker_tokens
|
|
614
|
+
if remaining <= 0:
|
|
615
|
+
omitted = dict(item)
|
|
616
|
+
omitted["tokens_estimate_original"] = item["tokens_estimate"]
|
|
617
|
+
omitted["content"] = ""
|
|
618
|
+
omitted["tokens_estimate"] = 0
|
|
619
|
+
omitted["omitted"] = True
|
|
620
|
+
result.append(omitted)
|
|
621
|
+
budget_spent = True
|
|
622
|
+
continue
|
|
623
|
+
|
|
624
|
+
# Slice so the kept prefix's own token estimate fits the remaining
|
|
625
|
+
# budget. A fixed chars*4 slice overshoots for CJK (issue #20).
|
|
626
|
+
truncated = (
|
|
627
|
+
_slice_to_token_prefix(item["content"], remaining) + TRUNCATION_MARKER
|
|
628
|
+
)
|
|
629
|
+
item_copy = dict(item)
|
|
630
|
+
item_copy["content"] = truncated
|
|
631
|
+
item_copy["tokens_estimate"] = estimate_tokens(truncated)
|
|
632
|
+
item_copy["truncated"] = True
|
|
633
|
+
result.append(item_copy)
|
|
634
|
+
total += item_copy["tokens_estimate"]
|
|
635
|
+
budget_spent = True
|
|
636
|
+
|
|
637
|
+
return result, is_incomplete
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _fence_for(content: str) -> str:
|
|
641
|
+
"""Return a backtick fence that cannot appear inside content.
|
|
642
|
+
|
|
643
|
+
CommonMark closes a fence only on a run of backticks at least as long
|
|
644
|
+
as the opening fence, so picking one more than the longest backtick
|
|
645
|
+
run in the content guarantees the file body can never terminate it --
|
|
646
|
+
including lines like `` ```python `` inside markdown files (issue #24).
|
|
647
|
+
"""
|
|
648
|
+
longest = 0
|
|
649
|
+
for match in re.finditer(r"`+", content):
|
|
650
|
+
longest = max(longest, len(match.group()))
|
|
651
|
+
return "`" * max(3, longest + 1)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def generate_markdown(
|
|
655
|
+
inventory: list[dict],
|
|
656
|
+
root: Path,
|
|
657
|
+
is_incomplete: bool,
|
|
658
|
+
show_absolute_paths: bool = False,
|
|
659
|
+
) -> str:
|
|
660
|
+
"""Generate markdown output."""
|
|
661
|
+
included = [i for i in inventory if not i.get("omitted")]
|
|
662
|
+
omitted = [i for i in inventory if i.get("omitted")]
|
|
663
|
+
lines = [
|
|
664
|
+
"# ctxpack Context Pack",
|
|
665
|
+
"",
|
|
666
|
+
f"Generated from: `{root.resolve() if show_absolute_paths else '.'}`",
|
|
667
|
+
f"Files included: {len(included)}",
|
|
668
|
+
]
|
|
669
|
+
if omitted:
|
|
670
|
+
lines.append(f"Files omitted (over budget): {len(omitted)}")
|
|
671
|
+
if is_incomplete:
|
|
672
|
+
lines.append(
|
|
673
|
+
"⚠️ **WARNING**: Total repository tokens exceeded the budget. "
|
|
674
|
+
"Some files are truncated or omitted."
|
|
675
|
+
)
|
|
676
|
+
lines.extend(["", "---", ""])
|
|
677
|
+
|
|
678
|
+
for item in included:
|
|
679
|
+
lines.append(f"## {item['path']}")
|
|
680
|
+
lines.append(
|
|
681
|
+
f"Size: {item['size_bytes']} bytes | Est. tokens: {item['tokens_estimate']}"
|
|
682
|
+
)
|
|
683
|
+
if item.get("truncated"):
|
|
684
|
+
lines.append("*⚠️ Truncated to fit token budget*")
|
|
685
|
+
fence = _fence_for(item["content"])
|
|
686
|
+
lines.extend(["", f"{fence}text", item["content"], fence, ""])
|
|
687
|
+
|
|
688
|
+
if omitted:
|
|
689
|
+
# List omitted paths so the reader knows what is missing from the pack.
|
|
690
|
+
lines.extend(
|
|
691
|
+
[
|
|
692
|
+
"## Omitted files",
|
|
693
|
+
"",
|
|
694
|
+
"These files were not included because the token budget was exhausted:",
|
|
695
|
+
"",
|
|
696
|
+
]
|
|
697
|
+
)
|
|
698
|
+
for item in omitted:
|
|
699
|
+
lines.append(
|
|
700
|
+
f"- `{item['path']}` ({item['size_bytes']} bytes, "
|
|
701
|
+
f"~{item.get('tokens_estimate_original', 0) or 0} tokens)"
|
|
702
|
+
)
|
|
703
|
+
lines.append("")
|
|
704
|
+
|
|
705
|
+
return "\n".join(lines)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def generate_json(
|
|
709
|
+
inventory: list[dict],
|
|
710
|
+
root: Path,
|
|
711
|
+
budget: int,
|
|
712
|
+
is_incomplete: bool,
|
|
713
|
+
show_absolute_paths: bool = False,
|
|
714
|
+
) -> dict:
|
|
715
|
+
"""Generate JSON output."""
|
|
716
|
+
included = [i for i in inventory if not i.get("omitted")]
|
|
717
|
+
return {
|
|
718
|
+
"generator": "ctxpack",
|
|
719
|
+
"root": str(root.resolve() if show_absolute_paths else "."),
|
|
720
|
+
"budget_tokens": budget,
|
|
721
|
+
"is_incomplete": is_incomplete,
|
|
722
|
+
"files_included": len(included),
|
|
723
|
+
"files_omitted": len(inventory) - len(included),
|
|
724
|
+
"files": [
|
|
725
|
+
{
|
|
726
|
+
"path": item["path"],
|
|
727
|
+
"size_bytes": item["size_bytes"],
|
|
728
|
+
"tokens_estimate": item["tokens_estimate"],
|
|
729
|
+
"truncated": item.get("truncated", False),
|
|
730
|
+
"omitted": item.get("omitted", False),
|
|
731
|
+
"content": item["content"],
|
|
732
|
+
}
|
|
733
|
+
for item in inventory
|
|
734
|
+
],
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def print_summary(inventory: list[dict], original_inventory: list[dict], budget: int):
|
|
739
|
+
"""Print human-readable summary to stdout.
|
|
740
|
+
|
|
741
|
+
The trimmed inventory retains omitted files as tombstones
|
|
742
|
+
(omitted=True, empty content) so paths are never silently dropped.
|
|
743
|
+
Count only non-omitted entries here to match the "Files included"
|
|
744
|
+
header in generate_markdown and files_included in generate_json.
|
|
745
|
+
"""
|
|
746
|
+
included = [i for i in inventory if not i.get("omitted")]
|
|
747
|
+
omitted_count = len(inventory) - len(included)
|
|
748
|
+
total_tokens = sum(item["tokens_estimate"] for item in inventory)
|
|
749
|
+
original_tokens = sum(item["tokens_estimate"] for item in original_inventory)
|
|
750
|
+
truncated_count = sum(1 for item in inventory if item.get("truncated"))
|
|
751
|
+
|
|
752
|
+
largest = (
|
|
753
|
+
max(original_inventory, key=lambda x: x["tokens_estimate"])
|
|
754
|
+
if original_inventory
|
|
755
|
+
else {"path": "N/A", "tokens_estimate": 0}
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
pct = (total_tokens / budget * 100) if budget > 0 else 0
|
|
759
|
+
|
|
760
|
+
print("\n" + "=" * 40)
|
|
761
|
+
print(" ctxpack Summary")
|
|
762
|
+
print("=" * 40)
|
|
763
|
+
print(f"Files included: {len(included)}")
|
|
764
|
+
if omitted_count:
|
|
765
|
+
print(f"Files omitted: {omitted_count}")
|
|
766
|
+
print(f"Total tokens: {total_tokens:,} / {budget:,} ({pct:.1f}%)")
|
|
767
|
+
print(
|
|
768
|
+
f"Largest file: {largest['path']} ({largest['tokens_estimate']:,} tokens)"
|
|
769
|
+
)
|
|
770
|
+
print(f"Truncated files: {truncated_count}")
|
|
771
|
+
if original_tokens > budget:
|
|
772
|
+
print("⚠️ WARNING: Original repo exceeded budget. Pack is incomplete.")
|
|
773
|
+
print("=" * 40 + "\n")
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def cmd_init(args):
|
|
777
|
+
"""Create default .ctxignore and ctxpack.json if missing."""
|
|
778
|
+
root = Path.cwd()
|
|
779
|
+
default_ignore = (
|
|
780
|
+
"# ctxpack ignore patterns (gitignore-style)\n"
|
|
781
|
+
".git/\n"
|
|
782
|
+
".svn/\n"
|
|
783
|
+
".hg/\n"
|
|
784
|
+
"__pycache__/\n"
|
|
785
|
+
"*.pyc\n"
|
|
786
|
+
"*.pyo\n"
|
|
787
|
+
"node_modules/\n"
|
|
788
|
+
"venv/\n"
|
|
789
|
+
".venv/\n"
|
|
790
|
+
"dist/\n"
|
|
791
|
+
"build/\n"
|
|
792
|
+
".ruff_cache/\n"
|
|
793
|
+
".pytest_cache/\n"
|
|
794
|
+
".mypy_cache/\n"
|
|
795
|
+
".tox/\n"
|
|
796
|
+
".eggs/\n"
|
|
797
|
+
"htmlcov/\n"
|
|
798
|
+
".coverage\n"
|
|
799
|
+
"*.log\n"
|
|
800
|
+
"*.lock\n"
|
|
801
|
+
"package-lock.json\n"
|
|
802
|
+
".DS_Store\n"
|
|
803
|
+
"Thumbs.db\n"
|
|
804
|
+
"ctxpack.context.json\n"
|
|
805
|
+
"ctxpack.context.md\n"
|
|
806
|
+
"# Secret-safe defaults (credentials never reach the pack)\n"
|
|
807
|
+
".env\n"
|
|
808
|
+
".env.*\n"
|
|
809
|
+
"!.env.example\n"
|
|
810
|
+
"*.pem\n"
|
|
811
|
+
"*.key\n"
|
|
812
|
+
"*.p12\n"
|
|
813
|
+
"*.pfx\n"
|
|
814
|
+
"*.crt\n"
|
|
815
|
+
"*.cer\n"
|
|
816
|
+
"*.gpg\n"
|
|
817
|
+
"*.asc\n"
|
|
818
|
+
"**/.aws/**\n"
|
|
819
|
+
"**/.ssh/**\n"
|
|
820
|
+
"**/.netrc\n"
|
|
821
|
+
"**/.npmrc\n"
|
|
822
|
+
"**/.pypirc\n"
|
|
823
|
+
)
|
|
824
|
+
default_config = json.dumps(
|
|
825
|
+
{
|
|
826
|
+
"budget_tokens": 8000,
|
|
827
|
+
"include": [],
|
|
828
|
+
"exclude": [],
|
|
829
|
+
"output_dir": ".",
|
|
830
|
+
"base_name": "ctxpack",
|
|
831
|
+
},
|
|
832
|
+
indent=2,
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
ignore_path = root / DEFAULT_IGNORE_FILE
|
|
836
|
+
config_path = root / DEFAULT_CONFIG_FILE
|
|
837
|
+
|
|
838
|
+
if not ignore_path.exists():
|
|
839
|
+
ignore_path.write_text(default_ignore, encoding="utf-8")
|
|
840
|
+
print(f"Created {DEFAULT_IGNORE_FILE}")
|
|
841
|
+
else:
|
|
842
|
+
print(f"{DEFAULT_IGNORE_FILE} already exists")
|
|
843
|
+
|
|
844
|
+
if not config_path.exists():
|
|
845
|
+
config_path.write_text(default_config, encoding="utf-8")
|
|
846
|
+
print(f"Created {DEFAULT_CONFIG_FILE}")
|
|
847
|
+
else:
|
|
848
|
+
print(f"{DEFAULT_CONFIG_FILE} already exists")
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def resolve_settings(args, config: dict) -> tuple[int, str, str]:
|
|
852
|
+
"""Resolve budget/output_dir/base_name with precedence CLI > config > default.
|
|
853
|
+
|
|
854
|
+
Uses None as the "not supplied" sentinel rather than comparing against the
|
|
855
|
+
default value. Comparing against the default made `--budget 8000` (or
|
|
856
|
+
`--output-dir .`) indistinguishable from omitting the flag, so config
|
|
857
|
+
silently overrode an explicit choice.
|
|
858
|
+
"""
|
|
859
|
+
budget = getattr(args, "budget", None)
|
|
860
|
+
if budget is None:
|
|
861
|
+
budget = config.get("budget_tokens", DEFAULT_BUDGET_TOKENS)
|
|
862
|
+
|
|
863
|
+
if budget < 0:
|
|
864
|
+
raise ValueError(f"budget must be >= 0, got {budget}")
|
|
865
|
+
|
|
866
|
+
output_dir = getattr(args, "output_dir", None)
|
|
867
|
+
if output_dir is None:
|
|
868
|
+
output_dir = config.get("output_dir", DEFAULT_OUTPUT_DIR)
|
|
869
|
+
|
|
870
|
+
base_name = getattr(args, "base_name", None)
|
|
871
|
+
if base_name is None:
|
|
872
|
+
base_name = config.get("base_name", DEFAULT_BASE_NAME)
|
|
873
|
+
|
|
874
|
+
return budget, output_dir, base_name
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def resolve_patterns(args, config: dict) -> tuple[list[str], list[str]]:
|
|
878
|
+
"""Resolve include/exclude patterns with precedence CLI > config."""
|
|
879
|
+
include_patterns: list[str] = []
|
|
880
|
+
if getattr(args, "include", None):
|
|
881
|
+
include_patterns.extend(p.strip() for p in args.include.split(",") if p.strip())
|
|
882
|
+
elif config.get("include"):
|
|
883
|
+
include_patterns.extend(config["include"])
|
|
884
|
+
|
|
885
|
+
exclude_patterns: list[str] = []
|
|
886
|
+
if getattr(args, "exclude", None):
|
|
887
|
+
exclude_patterns.extend(p.strip() for p in args.exclude.split(",") if p.strip())
|
|
888
|
+
elif config.get("exclude"):
|
|
889
|
+
exclude_patterns.extend(config["exclude"])
|
|
890
|
+
|
|
891
|
+
return include_patterns, exclude_patterns
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def cmd_pack(args):
|
|
895
|
+
"""Scan repo and build context pack."""
|
|
896
|
+
root = Path.cwd()
|
|
897
|
+
|
|
898
|
+
# Load config unless --no-config is set
|
|
899
|
+
config = {}
|
|
900
|
+
if not getattr(args, "no_config", False):
|
|
901
|
+
config = load_config(root)
|
|
902
|
+
|
|
903
|
+
budget, output_dir, base_name = resolve_settings(args, config)
|
|
904
|
+
include_patterns, cli_exclude = resolve_patterns(args, config)
|
|
905
|
+
|
|
906
|
+
# Merge the built-in defaults and .ctxignore with any CLI/config excludes.
|
|
907
|
+
# The v0.2.0 rewrite left load_ignore_patterns() defined but never called,
|
|
908
|
+
# so .ctxignore and every default (.git, venv, *.log, ...) were ignored.
|
|
909
|
+
exclude_patterns = load_ignore_patterns(
|
|
910
|
+
root, cli_exclude, strict_secrets=getattr(args, "strict_secrets", False)
|
|
911
|
+
)
|
|
912
|
+
|
|
913
|
+
# Never re-pack our own previous output: the defaults only cover
|
|
914
|
+
# ctxpack.context.*, so a custom base_name's outputs would be scanned as
|
|
915
|
+
# source on the next run and compound each time (issue #23).
|
|
916
|
+
exclude_patterns.append(f"{base_name}.context.md")
|
|
917
|
+
exclude_patterns.append(f"{base_name}.context.json")
|
|
918
|
+
|
|
919
|
+
print(f"Scanning {root} ...")
|
|
920
|
+
original_inventory = build_file_inventory(root, include_patterns, exclude_patterns)
|
|
921
|
+
print(f"Found {len(original_inventory)} text files before budget trim.")
|
|
922
|
+
|
|
923
|
+
inventory, is_incomplete = trim_to_budget(original_inventory, budget)
|
|
924
|
+
|
|
925
|
+
# Resolve output paths relative to the scanned root, not the process cwd,
|
|
926
|
+
# so ctxpack writes beside the repo it scanned rather than wherever the
|
|
927
|
+
# shell happens to be.
|
|
928
|
+
out_dir = Path(output_dir)
|
|
929
|
+
if not out_dir.is_absolute():
|
|
930
|
+
out_dir = root / out_dir
|
|
931
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
932
|
+
md_path = out_dir / f"{base_name}.context.md"
|
|
933
|
+
json_path = out_dir / f"{base_name}.context.json"
|
|
934
|
+
|
|
935
|
+
md_path.write_text(
|
|
936
|
+
generate_markdown(
|
|
937
|
+
inventory,
|
|
938
|
+
root,
|
|
939
|
+
is_incomplete,
|
|
940
|
+
show_absolute_paths=getattr(args, "show_absolute_paths", False),
|
|
941
|
+
),
|
|
942
|
+
encoding="utf-8",
|
|
943
|
+
)
|
|
944
|
+
json_path.write_text(
|
|
945
|
+
json.dumps(
|
|
946
|
+
generate_json(
|
|
947
|
+
inventory,
|
|
948
|
+
root,
|
|
949
|
+
budget,
|
|
950
|
+
is_incomplete,
|
|
951
|
+
show_absolute_paths=getattr(args, "show_absolute_paths", False),
|
|
952
|
+
),
|
|
953
|
+
indent=2,
|
|
954
|
+
),
|
|
955
|
+
encoding="utf-8",
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
print(f"Wrote {md_path}")
|
|
959
|
+
print(f"Wrote {json_path}")
|
|
960
|
+
|
|
961
|
+
print_summary(inventory, original_inventory, budget)
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def main():
|
|
965
|
+
parser = argparse.ArgumentParser(
|
|
966
|
+
description="ctxpack - build token-budgeted context packs for AI workflows"
|
|
967
|
+
)
|
|
968
|
+
parser.add_argument("--version", action="version", version=f"ctxpack {__version__}")
|
|
969
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
970
|
+
|
|
971
|
+
init_parser = subparsers.add_parser(
|
|
972
|
+
"init", help="create default .ctxignore and ctxpack.json"
|
|
973
|
+
)
|
|
974
|
+
init_parser.set_defaults(func=cmd_init)
|
|
975
|
+
|
|
976
|
+
pack_parser = subparsers.add_parser("pack", help="scan repo and build context pack")
|
|
977
|
+
pack_parser.add_argument(
|
|
978
|
+
"--budget",
|
|
979
|
+
type=int,
|
|
980
|
+
default=None,
|
|
981
|
+
help=(
|
|
982
|
+
f"max estimated token budget (chars/4 heuristic, "
|
|
983
|
+
f"default {DEFAULT_BUDGET_TOKENS})"
|
|
984
|
+
),
|
|
985
|
+
)
|
|
986
|
+
pack_parser.add_argument(
|
|
987
|
+
"--include",
|
|
988
|
+
type=str,
|
|
989
|
+
default=None,
|
|
990
|
+
help="comma-separated include patterns (e.g., 'src/**,tests/**')",
|
|
991
|
+
)
|
|
992
|
+
pack_parser.add_argument(
|
|
993
|
+
"--exclude",
|
|
994
|
+
type=str,
|
|
995
|
+
default=None,
|
|
996
|
+
help="comma-separated exclude patterns (takes precedence)",
|
|
997
|
+
)
|
|
998
|
+
pack_parser.add_argument(
|
|
999
|
+
"--output-dir",
|
|
1000
|
+
type=str,
|
|
1001
|
+
default=None,
|
|
1002
|
+
help="directory for output files (default: '.')",
|
|
1003
|
+
)
|
|
1004
|
+
pack_parser.add_argument(
|
|
1005
|
+
"--base-name",
|
|
1006
|
+
type=str,
|
|
1007
|
+
default=None,
|
|
1008
|
+
help="base name for output files (default: 'ctxpack')",
|
|
1009
|
+
)
|
|
1010
|
+
pack_parser.add_argument(
|
|
1011
|
+
"--no-config", action="store_true", help="ignore ctxpack.json settings"
|
|
1012
|
+
)
|
|
1013
|
+
pack_parser.add_argument(
|
|
1014
|
+
"--strict-secrets",
|
|
1015
|
+
action="store_true",
|
|
1016
|
+
help=(
|
|
1017
|
+
"make default secret exclusions non-overridable: .ctxignore and "
|
|
1018
|
+
"--exclude negation patterns cannot re-include secret files "
|
|
1019
|
+
"(.env.example templates remain allowed)"
|
|
1020
|
+
),
|
|
1021
|
+
)
|
|
1022
|
+
pack_parser.add_argument(
|
|
1023
|
+
"--show-absolute-paths",
|
|
1024
|
+
action="store_true",
|
|
1025
|
+
help=(
|
|
1026
|
+
"include the resolved absolute root path in output "
|
|
1027
|
+
"(default: privacy-preserving '.')"
|
|
1028
|
+
),
|
|
1029
|
+
)
|
|
1030
|
+
pack_parser.set_defaults(func=cmd_pack)
|
|
1031
|
+
|
|
1032
|
+
args = parser.parse_args()
|
|
1033
|
+
args.func(args)
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
if __name__ == "__main__":
|
|
1037
|
+
main()
|