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()