codecrate 0.1.0__tar.gz → 0.1.2__tar.gz

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.

Potentially problematic release.


This version of codecrate might be problematic. Click here for more details.

Files changed (65) hide show
  1. {codecrate-0.1.0 → codecrate-0.1.2}/.gitignore +1 -0
  2. codecrate-0.1.2/AGENTS.md +159 -0
  3. {codecrate-0.1.0 → codecrate-0.1.2}/PKG-INFO +1 -1
  4. {codecrate-0.1.0 → codecrate-0.1.2}/codecrate/_version.py +3 -3
  5. codecrate-0.1.2/codecrate/cli.py +433 -0
  6. {codecrate-0.1.0 → codecrate-0.1.2}/codecrate/config.py +13 -4
  7. {codecrate-0.1.0 → codecrate-0.1.2}/codecrate/parse.py +3 -1
  8. {codecrate-0.1.0 → codecrate-0.1.2}/codecrate.egg-info/PKG-INFO +1 -1
  9. {codecrate-0.1.0 → codecrate-0.1.2}/codecrate.egg-info/SOURCES.txt +2 -0
  10. {codecrate-0.1.0 → codecrate-0.1.2}/docs/cli.rst +15 -6
  11. {codecrate-0.1.0 → codecrate-0.1.2}/docs/quickstart.rst +6 -0
  12. codecrate-0.1.2/tests/test_cli_pack_multi.py +39 -0
  13. codecrate-0.1.0/codecrate/cli.py +0 -250
  14. {codecrate-0.1.0 → codecrate-0.1.2}/.github/pytest.ini +0 -0
  15. {codecrate-0.1.0 → codecrate-0.1.2}/.github/workflows/codecov.yml +0 -0
  16. {codecrate-0.1.0 → codecrate-0.1.2}/.github/workflows/pre-commit.yml +0 -0
  17. {codecrate-0.1.0 → codecrate-0.1.2}/.github/workflows/python-publish.yml +0 -0
  18. {codecrate-0.1.0 → codecrate-0.1.2}/.github/workflows/tests.yml +0 -0
  19. {codecrate-0.1.0 → codecrate-0.1.2}/.pre-commit-config.yaml +0 -0
  20. {codecrate-0.1.0 → codecrate-0.1.2}/.readthedocs.yaml +0 -0
  21. {codecrate-0.1.0 → codecrate-0.1.2}/.ruff.toml +0 -0
  22. {codecrate-0.1.0 → codecrate-0.1.2}/LICENSE +0 -0
  23. {codecrate-0.1.0 → codecrate-0.1.2}/README.md +0 -0
  24. {codecrate-0.1.0 → codecrate-0.1.2}/codecrate/__init__.py +0 -0
  25. {codecrate-0.1.0 → codecrate-0.1.2}/codecrate/diffgen.py +0 -0
  26. {codecrate-0.1.0 → codecrate-0.1.2}/codecrate/discover.py +0 -0
  27. {codecrate-0.1.0 → codecrate-0.1.2}/codecrate/ids.py +0 -0
  28. {codecrate-0.1.0 → codecrate-0.1.2}/codecrate/manifest.py +0 -0
  29. {codecrate-0.1.0 → codecrate-0.1.2}/codecrate/markdown.py +0 -0
  30. {codecrate-0.1.0 → codecrate-0.1.2}/codecrate/mdparse.py +0 -0
  31. {codecrate-0.1.0 → codecrate-0.1.2}/codecrate/model.py +0 -0
  32. {codecrate-0.1.0 → codecrate-0.1.2}/codecrate/packer.py +0 -0
  33. {codecrate-0.1.0 → codecrate-0.1.2}/codecrate/stubber.py +0 -0
  34. {codecrate-0.1.0 → codecrate-0.1.2}/codecrate/token_budget.py +0 -0
  35. {codecrate-0.1.0 → codecrate-0.1.2}/codecrate/udiff.py +0 -0
  36. {codecrate-0.1.0 → codecrate-0.1.2}/codecrate/unpacker.py +0 -0
  37. {codecrate-0.1.0 → codecrate-0.1.2}/codecrate/validate.py +0 -0
  38. {codecrate-0.1.0 → codecrate-0.1.2}/codecrate.egg-info/dependency_links.txt +0 -0
  39. {codecrate-0.1.0 → codecrate-0.1.2}/codecrate.egg-info/entry_points.txt +0 -0
  40. {codecrate-0.1.0 → codecrate-0.1.2}/codecrate.egg-info/requires.txt +0 -0
  41. {codecrate-0.1.0 → codecrate-0.1.2}/codecrate.egg-info/top_level.txt +0 -0
  42. {codecrate-0.1.0 → codecrate-0.1.2}/codecrate.toml +0 -0
  43. {codecrate-0.1.0 → codecrate-0.1.2}/docs/api.rst +0 -0
  44. {codecrate-0.1.0 → codecrate-0.1.2}/docs/conf.py +0 -0
  45. {codecrate-0.1.0 → codecrate-0.1.2}/docs/format.rst +0 -0
  46. {codecrate-0.1.0 → codecrate-0.1.2}/docs/index.rst +0 -0
  47. {codecrate-0.1.0 → codecrate-0.1.2}/docs/make.bat +0 -0
  48. {codecrate-0.1.0 → codecrate-0.1.2}/docs/make.py +0 -0
  49. {codecrate-0.1.0 → codecrate-0.1.2}/docs/requirements.txt +0 -0
  50. {codecrate-0.1.0 → codecrate-0.1.2}/pyproject.toml +0 -0
  51. {codecrate-0.1.0 → codecrate-0.1.2}/requirements-test.txt +0 -0
  52. {codecrate-0.1.0 → codecrate-0.1.2}/setup.cfg +0 -0
  53. {codecrate-0.1.0 → codecrate-0.1.2}/setup.py +0 -0
  54. {codecrate-0.1.0 → codecrate-0.1.2}/tests/__init__.py +0 -0
  55. {codecrate-0.1.0 → codecrate-0.1.2}/tests/test_config.py +0 -0
  56. {codecrate-0.1.0 → codecrate-0.1.2}/tests/test_discover.py +0 -0
  57. {codecrate-0.1.0 → codecrate-0.1.2}/tests/test_ids.py +0 -0
  58. {codecrate-0.1.0 → codecrate-0.1.2}/tests/test_markdown_line_numbers.py +0 -0
  59. {codecrate-0.1.0 → codecrate-0.1.2}/tests/test_model.py +0 -0
  60. {codecrate-0.1.0 → codecrate-0.1.2}/tests/test_pack_unpack_roundtrip.py +0 -0
  61. {codecrate-0.1.0 → codecrate-0.1.2}/tests/test_parse.py +0 -0
  62. {codecrate-0.1.0 → codecrate-0.1.2}/tests/test_patch_apply_roundtrip.py +0 -0
  63. {codecrate-0.1.0 → codecrate-0.1.2}/tests/test_smoke.py +0 -0
  64. {codecrate-0.1.0 → codecrate-0.1.2}/tests/test_split_codecrate_pack.py +0 -0
  65. {codecrate-0.1.0 → codecrate-0.1.2}/tests/test_token_budget.py +0 -0
@@ -206,3 +206,4 @@ marimo/_static/
206
206
  marimo/_lsp/
207
207
  __marimo__/
208
208
  codecrate/_version.py
209
+ context_codecrate.md
@@ -0,0 +1,159 @@
1
+ # AGENTS.md
2
+
3
+ This file summarizes how to work in this repository for agentic tooling.
4
+ Follow these conventions unless a task explicitly requires otherwise.
5
+
6
+ ## Repository Overview
7
+
8
+ - Project: `codecrate` (Python library + CLI)
9
+ - Package directory: `codecrate/`
10
+ - Tests: `tests/` with pytest
11
+ - Python support: 3.10+ (see `pyproject.toml`)
12
+ - Lint/format tooling: ruff + ruff-format (`.ruff.toml`)
13
+ - Pre-commit hooks: ruff, ruff-format, plus general hooks
14
+
15
+ ## Key Config Files
16
+
17
+ - `pyproject.toml`: build metadata, optional deps, mypy config
18
+ - `.ruff.toml`: lint + format rules (target version, import order, complexity)
19
+ - `.pre-commit-config.yaml`: formatting and linting hooks
20
+ - `.github/workflows/tests.yml`: CI test commands
21
+ - `.github/pytest.ini`: pytest defaults (`-rw`, ignore dirs)
22
+ - `codecrate.toml`: runtime config for pack/unpack behavior
23
+
24
+ ## Setup
25
+
26
+ Common install paths used in CI and docs:
27
+
28
+ - `uv pip install -e .`
29
+
30
+ ## Build, Lint, Format, Test Commands
31
+
32
+ Run these from the repository root:
33
+
34
+ - Format: `ruff format .`
35
+ - Lint: `ruff check .`
36
+ - Lint + autofix: `ruff check --fix .`
37
+ - All tests: `pytest`
38
+ - Pre-commit hooks: `pre-commit run --all-files`
39
+
40
+ ## Running a Single Test
41
+
42
+ Typical pytest patterns:
43
+
44
+ - One file: `pytest tests/test_parse.py`
45
+ - One test: `pytest tests/test_parse.py::test_parse_basic`
46
+ - By keyword: `pytest -k line_numbers`
47
+ - Show stdout: `pytest -s tests/test_smoke.py::test_cli_pack`
48
+
49
+ Pytest defaults (`.github/pytest.ini`) include:
50
+
51
+ - `addopts = -rw`
52
+ - `norecursedirs = .git .* *.egg* old dist build`
53
+
54
+ ## Formatting and Linting Rules
55
+
56
+ Ruff is the source of truth for formatting and linting.
57
+ Configuration lives in `.ruff.toml`.
58
+
59
+ - Target Python: 3.10 (`target-version = "py310"`)
60
+ - Line length: ruff E501 defaults (88 unless configured otherwise)
61
+ - Import sorting: ruff isort with sections
62
+ - Complexity limit: McCabe max 22
63
+
64
+ When editing code, prefer running:
65
+
66
+ - `ruff format .`
67
+ - `ruff check --fix .`
68
+
69
+ ## Import Conventions
70
+
71
+ Follow the existing import layout:
72
+
73
+ - `from __future__ import annotations` at the top of each module
74
+ - Standard library imports first
75
+ - Third-party imports next (rare here)
76
+ - First-party imports last (`codecrate.*` or relative)
77
+ - Keep imports sorted by ruff/isort
78
+
79
+ `__init__.py` files may intentionally have unused imports; ruff ignores
80
+ F401/I001 there (see `.ruff.toml`).
81
+
82
+ ## Type Hints and Static Analysis
83
+
84
+ Type hints are expected on public functions and helpers.
85
+ The mypy config is strict, so avoid untyped defs when possible.
86
+
87
+ - Use `list[str]`, `dict[str, str]`, etc. (PEP 585 style)
88
+ - Prefer explicit `Optional[T]` via `T | None`
89
+ - Avoid implicit `Any` in new code
90
+
91
+ ## Naming Conventions
92
+
93
+ - Modules, functions, variables: `snake_case`
94
+ - Classes, dataclasses: `PascalCase`
95
+ - Constants: `UPPER_SNAKE_CASE`
96
+ - Private helpers: `_leading_underscore`
97
+
98
+ Follow existing naming in each module; avoid inventing new prefixes.
99
+
100
+ ## File and Path Handling
101
+
102
+ The codebase favors `pathlib.Path`:
103
+
104
+ - Use `Path` instead of `os.path`
105
+ - For file IO, use `read_text`/`write_text`
106
+ - When reading text, pass `encoding="utf-8"` and `errors="replace"`
107
+ - Use `as_posix()` when storing relative paths
108
+
109
+ ## Error Handling and Warnings
110
+
111
+ Keep error handling explicit and narrow:
112
+
113
+ - Raise `ValueError` for invalid user input
114
+ - Use `warnings.warn(..., RuntimeWarning)` for recoverable issues
115
+ - Avoid bare `except:` and broad `except Exception:` unless needed
116
+ - Prefer returning empty values over crashing when data is missing
117
+
118
+ ## General Coding Practices
119
+
120
+ - Prefer small, focused helpers with explicit inputs/outputs
121
+ - Keep side effects near the CLI or IO boundaries
122
+ - Use dataclasses for structured data (see `codecrate/model.py`)
123
+ - Preserve existing section ordering in generated markdown
124
+
125
+ ## Markdown and Output Formatting
126
+
127
+ Generated Markdown is line-sensitive. When modifying output:
128
+
129
+ - Preserve existing headings and section order
130
+ - Keep code fences exact (` ```python ` or ` ```diff `)
131
+ - Avoid adding trailing whitespace
132
+
133
+ ## Tests and Fixtures
134
+
135
+ Tests are pytest-based and generally use `tmp_path`.
136
+ Keep tests deterministic and avoid external IO or network access.
137
+
138
+ - Use `tmp_path` for temp repos
139
+ - Use `Path.write_text(..., encoding="utf-8")`
140
+ - Prefer exact string comparisons for rendered output
141
+
142
+ ## CLI and Commands
143
+
144
+ The CLI entrypoint is `codecrate.cli:main`.
145
+ When adding CLI flags, update both the parser and README if needed.
146
+
147
+ Quick reference:
148
+
149
+ - Pack: `codecrate pack . -o context.md`
150
+ - Unpack: `codecrate unpack context.md -o out_dir/`
151
+ - Patch: `codecrate patch baseline.md . -o changes.md`
152
+ - Apply: `codecrate apply changes.md .`
153
+ - Validate: `codecrate validate-pack context.md`
154
+
155
+ ## Docs
156
+
157
+ Sphinx config lives under `docs/` (see `docs/conf.py`).
158
+ No automated doc build is configured in CI, but keep docs consistent
159
+ with CLI and configuration behavior.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codecrate
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Pack Python codebases into Markdown optimized for LLM context delivery (pack/unpack/patch/apply)
5
5
  Author-email: Holger Nahrstaedt <nahrstaedt@gmail.com>
6
6
  License: MIT License
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.1.0'
32
- __version_tuple__ = version_tuple = (0, 1, 0)
31
+ __version__ = version = '0.1.2'
32
+ __version_tuple__ = version_tuple = (0, 1, 2)
33
33
 
34
- __commit_id__ = commit_id = 'gbcb2c3a99'
34
+ __commit_id__ = commit_id = 'g3ede7bcd7'
@@ -0,0 +1,433 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from .config import Config, load_config
8
+ from .diffgen import generate_patch_markdown
9
+ from .discover import discover_files
10
+ from .markdown import render_markdown
11
+ from .packer import pack_repo
12
+ from .token_budget import split_by_max_chars
13
+ from .udiff import apply_file_diffs, parse_unified_diff
14
+ from .unpacker import unpack_to_dir
15
+ from .validate import validate_pack_markdown
16
+
17
+
18
+ def build_parser() -> argparse.ArgumentParser:
19
+ p = argparse.ArgumentParser(
20
+ prog="codecrate",
21
+ description="Pack/unpack/patch/apply for repositories (Python + text files).",
22
+ )
23
+ sub = p.add_subparsers(dest="cmd", required=True)
24
+
25
+ # pack
26
+ pack = sub.add_parser(
27
+ "pack", help="Pack one or more repositories/directories into Markdown."
28
+ )
29
+ pack.add_argument(
30
+ "root",
31
+ type=Path,
32
+ nargs="?",
33
+ help="Root directory to scan (omit when using --repo)",
34
+ )
35
+ pack.add_argument(
36
+ "--repo",
37
+ action="append",
38
+ default=None,
39
+ type=Path,
40
+ help="Additional repo root to pack (repeatable; use instead of ROOT)",
41
+ )
42
+ pack.add_argument(
43
+ "-o",
44
+ "--output",
45
+ type=Path,
46
+ default=None,
47
+ help="Output markdown path (default: config 'output' or context.md)",
48
+ )
49
+ pack.add_argument(
50
+ "--dedupe", action="store_true", help="Deduplicate identical function bodies"
51
+ )
52
+ pack.add_argument(
53
+ "--layout",
54
+ choices=["auto", "stubs", "full"],
55
+ default=None,
56
+ help="Output layout: auto|stubs|full (default: auto via config)",
57
+ )
58
+ pack.add_argument(
59
+ "--keep-docstrings",
60
+ action=argparse.BooleanOptionalAction,
61
+ default=None,
62
+ help="Keep docstrings in stubbed file view (default: true via config)",
63
+ )
64
+ pack.add_argument(
65
+ "--respect-gitignore",
66
+ action=argparse.BooleanOptionalAction,
67
+ default=None,
68
+ help="Respect .gitignore (default: true via config)",
69
+ )
70
+ pack.add_argument(
71
+ "--manifest",
72
+ action=argparse.BooleanOptionalAction,
73
+ default=None,
74
+ help="Include Manifest section (default: true via config)",
75
+ )
76
+ pack.add_argument(
77
+ "--include", action="append", default=None, help="Include glob (repeatable)"
78
+ )
79
+ pack.add_argument(
80
+ "--exclude", action="append", default=None, help="Exclude glob (repeatable)"
81
+ )
82
+ pack.add_argument(
83
+ "--split-max-chars",
84
+ type=int,
85
+ default=None,
86
+ help="Split output into .partN.md files",
87
+ )
88
+
89
+ # unpack
90
+ unpack = sub.add_parser(
91
+ "unpack", help="Reconstruct files from a packed context Markdown."
92
+ )
93
+ unpack.add_argument("markdown", type=Path, help="Packed Markdown file from `pack`")
94
+ unpack.add_argument(
95
+ "-o",
96
+ "--out-dir",
97
+ type=Path,
98
+ required=True,
99
+ help="Output directory for reconstructed files",
100
+ )
101
+
102
+ # patch
103
+ patch = sub.add_parser(
104
+ "patch",
105
+ help="Generate a diff-only patch Markdown from old pack + current repo.",
106
+ )
107
+ patch.add_argument(
108
+ "old_markdown", type=Path, help="Older packed Markdown (baseline)"
109
+ )
110
+ patch.add_argument("root", type=Path, help="Current repo root to compare against")
111
+ patch.add_argument(
112
+ "-o",
113
+ "--output",
114
+ type=Path,
115
+ default=Path("patch.md"),
116
+ help="Output patch markdown",
117
+ )
118
+
119
+ # apply
120
+ apply = sub.add_parser("apply", help="Apply a diff-only patch Markdown to a repo.")
121
+ apply.add_argument(
122
+ "patch_markdown", type=Path, help="Patch Markdown containing ```diff blocks"
123
+ )
124
+ apply.add_argument("root", type=Path, help="Repo root to apply patch to")
125
+ # validate-pack
126
+ vpack = sub.add_parser(
127
+ "validate-pack",
128
+ help="Validate a packed context Markdown (sha/markers/canonical consistency).",
129
+ )
130
+ vpack.add_argument("markdown", type=Path, help="Packed Markdown to validate")
131
+ vpack.add_argument(
132
+ "--root",
133
+ type=Path,
134
+ default=None,
135
+ help="Optional repo root to compare reconstructed files against",
136
+ )
137
+
138
+ return p
139
+
140
+
141
+ @dataclass(frozen=True)
142
+ class PackOptions:
143
+ include: list[str] | None
144
+ exclude: list[str] | None
145
+ keep_docstrings: bool
146
+ include_manifest: bool
147
+ respect_gitignore: bool
148
+ dedupe: bool
149
+ split_max_chars: int
150
+ layout: str
151
+
152
+
153
+ @dataclass(frozen=True)
154
+ class PackRun:
155
+ root: Path
156
+ label: str
157
+ slug: str
158
+ markdown: str
159
+ options: PackOptions
160
+ default_output: Path
161
+
162
+
163
+ def _resolve_pack_options(cfg: Config, args: argparse.Namespace) -> PackOptions:
164
+ include = args.include if args.include is not None else cfg.include
165
+ exclude = args.exclude if args.exclude is not None else cfg.exclude
166
+ keep_docstrings = (
167
+ cfg.keep_docstrings
168
+ if args.keep_docstrings is None
169
+ else bool(args.keep_docstrings)
170
+ )
171
+ include_manifest = cfg.manifest if args.manifest is None else bool(args.manifest)
172
+ respect_gitignore = (
173
+ cfg.respect_gitignore
174
+ if args.respect_gitignore is None
175
+ else bool(args.respect_gitignore)
176
+ )
177
+ dedupe = bool(args.dedupe) or bool(cfg.dedupe)
178
+ split_max_chars = (
179
+ cfg.split_max_chars
180
+ if args.split_max_chars is None
181
+ else int(args.split_max_chars or 0)
182
+ )
183
+ layout = (
184
+ str(args.layout).strip().lower()
185
+ if args.layout is not None
186
+ else str(getattr(cfg, "layout", "auto")).strip().lower()
187
+ )
188
+ return PackOptions(
189
+ include=include,
190
+ exclude=exclude,
191
+ keep_docstrings=keep_docstrings,
192
+ include_manifest=include_manifest,
193
+ respect_gitignore=respect_gitignore,
194
+ dedupe=dedupe,
195
+ split_max_chars=split_max_chars,
196
+ layout=layout,
197
+ )
198
+
199
+
200
+ def _resolve_output_path(cfg: Config, args: argparse.Namespace, root: Path) -> Path:
201
+ if args.output is not None:
202
+ return args.output
203
+ out_path = Path(getattr(cfg, "output", "context.md"))
204
+ if not out_path.is_absolute():
205
+ out_path = root / out_path
206
+ return out_path
207
+
208
+
209
+ def _default_repo_label(root: Path) -> str:
210
+ cwd = Path.cwd().resolve()
211
+ resolved = root.resolve()
212
+ try:
213
+ rel = resolved.relative_to(cwd).as_posix()
214
+ return rel or resolved.name or resolved.as_posix()
215
+ except ValueError:
216
+ return root.name or resolved.name or resolved.as_posix()
217
+
218
+
219
+ def _unique_label(root: Path, used: set[str]) -> str:
220
+ base = _default_repo_label(root)
221
+ label = base
222
+ idx = 2
223
+ while label in used:
224
+ label = f"{base}-{idx}"
225
+ idx += 1
226
+ used.add(label)
227
+ return label
228
+
229
+
230
+ def _slugify(label: str) -> str:
231
+ safe: list[str] = []
232
+ for ch in label:
233
+ if ch.isalnum() or ch in {"-", "_"}:
234
+ safe.append(ch)
235
+ else:
236
+ safe.append("-")
237
+ slug = "".join(safe).strip("-")
238
+ while "--" in slug:
239
+ slug = slug.replace("--", "-")
240
+ return slug or "repo"
241
+
242
+
243
+ def _unique_slug(label: str, used: set[str]) -> str:
244
+ base = _slugify(label)
245
+ slug = base
246
+ idx = 2
247
+ while slug in used:
248
+ slug = f"{base}-{idx}"
249
+ idx += 1
250
+ used.add(slug)
251
+ return slug
252
+
253
+
254
+ def _prefix_repo_header(text: str, label: str) -> str:
255
+ header = f"# Repository: {label}\n\n"
256
+ if text.startswith(header):
257
+ return text
258
+ return header + text
259
+
260
+
261
+ def _combine_pack_markdown(packs: list[PackRun]) -> str:
262
+ out: list[str] = []
263
+ for i, pack in enumerate(packs):
264
+ if i:
265
+ out.append("\n\n")
266
+ out.append(_prefix_repo_header(pack.markdown.rstrip() + "\n", pack.label))
267
+ return "".join(out).rstrip() + "\n"
268
+
269
+
270
+ def _extract_diff_blocks(md_text: str) -> str:
271
+ """
272
+ Extract only diff fences from markdown and concatenate to a unified diff string.
273
+ """
274
+ lines = md_text.splitlines()
275
+ out: list[str] = []
276
+ i = 0
277
+ while i < len(lines):
278
+ if lines[i].strip() == "```diff":
279
+ i += 1
280
+ while i < len(lines) and lines[i].strip() != "```":
281
+ out.append(lines[i])
282
+ i += 1
283
+ i += 1
284
+ return "\n".join(out) + "\n"
285
+
286
+
287
+ def main(argv: list[str] | None = None) -> None: # noqa: C901
288
+ parser = build_parser()
289
+ args = parser.parse_args(argv)
290
+
291
+ if args.cmd == "pack":
292
+ if args.repo:
293
+ if args.root is not None:
294
+ parser.error(
295
+ "pack: specify either ROOT or --repo (repeatable), not both"
296
+ )
297
+ roots = [r.resolve() for r in args.repo]
298
+ else:
299
+ if args.root is None:
300
+ parser.error("pack: ROOT is required when --repo is not used")
301
+ roots = [args.root.resolve()]
302
+
303
+ used_labels: set[str] = set()
304
+ used_slugs: set[str] = set()
305
+ pack_runs: list[PackRun] = []
306
+
307
+ for root in roots:
308
+ cfg = load_config(root)
309
+ options = _resolve_pack_options(cfg, args)
310
+ label = _unique_label(root, used_labels)
311
+ slug = _unique_slug(label, used_slugs)
312
+
313
+ disc = discover_files(
314
+ root=root,
315
+ include=options.include,
316
+ exclude=options.exclude,
317
+ respect_gitignore=options.respect_gitignore,
318
+ )
319
+ pack, canonical = pack_repo(
320
+ disc.root,
321
+ disc.files,
322
+ keep_docstrings=options.keep_docstrings,
323
+ dedupe=options.dedupe,
324
+ )
325
+ md = render_markdown(
326
+ pack,
327
+ canonical,
328
+ layout=options.layout,
329
+ include_manifest=options.include_manifest,
330
+ )
331
+ default_output = _resolve_output_path(cfg, args, root)
332
+ pack_runs.append(
333
+ PackRun(
334
+ root=root,
335
+ label=label,
336
+ slug=slug,
337
+ markdown=md,
338
+ options=options,
339
+ default_output=default_output,
340
+ )
341
+ )
342
+
343
+ out_path = (
344
+ args.output if args.output is not None else pack_runs[0].default_output
345
+ )
346
+ if len(pack_runs) == 1:
347
+ md = pack_runs[0].markdown
348
+ else:
349
+ md = _combine_pack_markdown(pack_runs)
350
+
351
+ # Always write the canonical, unsplit pack
352
+ # for machine parsing (unpack/validate).
353
+ out_path.write_text(md, encoding="utf-8")
354
+
355
+ extra_count = 0
356
+ if len(pack_runs) == 1:
357
+ split_max_chars = pack_runs[0].options.split_max_chars
358
+ parts = split_by_max_chars(md, out_path, split_max_chars)
359
+ extra = [p for p in parts if p.path != out_path]
360
+ for part in extra:
361
+ part.path.write_text(part.content, encoding="utf-8")
362
+ extra_count += len(extra)
363
+ else:
364
+ for pack in pack_runs:
365
+ if pack.options.split_max_chars <= 0:
366
+ continue
367
+ repo_base = out_path.with_name(
368
+ f"{out_path.stem}.{pack.slug}{out_path.suffix}"
369
+ )
370
+ parts = split_by_max_chars(
371
+ pack.markdown, repo_base, pack.options.split_max_chars
372
+ )
373
+ extra = [p for p in parts if p.path != repo_base]
374
+ for part in extra:
375
+ content = _prefix_repo_header(part.content, pack.label)
376
+ part.path.write_text(content, encoding="utf-8")
377
+ extra_count += len(extra)
378
+
379
+ if extra_count:
380
+ if len(pack_runs) == 1:
381
+ print(f"Wrote {out_path} and {extra_count} split part file(s).")
382
+ else:
383
+ print(
384
+ f"Wrote {out_path} and {extra_count} split part file(s) for "
385
+ f"{len(pack_runs)} repos."
386
+ )
387
+ else:
388
+ if len(pack_runs) == 1:
389
+ print(f"Wrote {out_path}.")
390
+ else:
391
+ print(f"Wrote {out_path} for {len(pack_runs)} repos.")
392
+ elif args.cmd == "unpack":
393
+ md_text = args.markdown.read_text(encoding="utf-8", errors="replace")
394
+ unpack_to_dir(md_text, args.out_dir)
395
+ print(f"Unpacked into {args.out_dir}")
396
+
397
+ elif args.cmd == "patch":
398
+ old_md = args.old_markdown.read_text(encoding="utf-8", errors="replace")
399
+ cfg = load_config(args.root)
400
+ patch_md = generate_patch_markdown(
401
+ old_md,
402
+ args.root,
403
+ include=cfg.include,
404
+ exclude=cfg.exclude,
405
+ respect_gitignore=cfg.respect_gitignore,
406
+ )
407
+ args.output.write_text(patch_md, encoding="utf-8")
408
+ print(f"Wrote {args.output}")
409
+
410
+ elif args.cmd == "validate-pack":
411
+ md_text = args.markdown.read_text(encoding="utf-8", errors="replace")
412
+ report = validate_pack_markdown(md_text, root=args.root)
413
+ if report.warnings:
414
+ print("Warnings:")
415
+ for w in report.warnings:
416
+ print(f"- {w}")
417
+ if report.errors:
418
+ print("Errors:")
419
+ for e in report.errors:
420
+ print(f"- {e}")
421
+ raise SystemExit(1)
422
+ print("OK: pack is internally consistent.")
423
+
424
+ elif args.cmd == "apply":
425
+ md_text = args.patch_markdown.read_text(encoding="utf-8", errors="replace")
426
+ diff_text = _extract_diff_blocks(md_text)
427
+ diffs = parse_unified_diff(diff_text)
428
+ changed = apply_file_diffs(diffs, args.root)
429
+ print(f"Applied patch to {len(changed)} file(s).")
430
+
431
+
432
+ if __name__ == "__main__":
433
+ main()
@@ -61,10 +61,19 @@ def load_config(root: Path) -> Config:
61
61
  return Config()
62
62
 
63
63
  data = tomllib.loads(cfg_path.read_text(encoding="utf-8"))
64
- section: dict[str, Any] = (
65
- data.get("codecrate", {}) if isinstance(data, dict) else {}
66
- )
67
-
64
+ section: dict[str, Any] = {}
65
+ if isinstance(data, dict):
66
+ # Preferred: [codecrate]
67
+ cc = data.get("codecrate")
68
+ if isinstance(cc, dict):
69
+ section = cc
70
+ else:
71
+ # Also accept: [tool.codecrate] (common convention from pyproject.toml)
72
+ tool = data.get("tool")
73
+ if isinstance(tool, dict):
74
+ cc2 = tool.get("codecrate")
75
+ if isinstance(cc2, dict):
76
+ section = cc2
68
77
  cfg = Config()
69
78
  out = section.get("output", cfg.output)
70
79
  if isinstance(out, str) and out.strip():
@@ -127,7 +127,9 @@ class _Visitor(ast.NodeVisitor):
127
127
  def parse_symbols(
128
128
  path: Path, root: Path, text: str
129
129
  ) -> tuple[list[ClassRef], list[DefRef]]:
130
- tree = ast.parse(text)
130
+ # Pass filename so SyntaxWarnings (e.g. invalid escape sequences) point to
131
+ # the real file instead of "<unknown>".
132
+ tree = ast.parse(text, filename=path.as_posix())
131
133
  v = _Visitor(path=path, root=root)
132
134
  v.visit(tree)
133
135
  return v.classes, v.defs
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codecrate
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Pack Python codebases into Markdown optimized for LLM context delivery (pack/unpack/patch/apply)
5
5
  Author-email: Holger Nahrstaedt <nahrstaedt@gmail.com>
6
6
  License: MIT License
@@ -2,6 +2,7 @@
2
2
  .pre-commit-config.yaml
3
3
  .readthedocs.yaml
4
4
  .ruff.toml
5
+ AGENTS.md
5
6
  LICENSE
6
7
  README.md
7
8
  codecrate.toml
@@ -47,6 +48,7 @@ docs/make.py
47
48
  docs/quickstart.rst
48
49
  docs/requirements.txt
49
50
  tests/__init__.py
51
+ tests/test_cli_pack_multi.py
50
52
  tests/test_config.py
51
53
  tests/test_discover.py
52
54
  tests/test_ids.py
@@ -26,7 +26,7 @@ Overview
26
26
 
27
27
  .. code-block:: console
28
28
 
29
- codecrate pack ROOT [options]
29
+ codecrate pack [ROOT] [--repo REPO ...] [options]
30
30
  codecrate unpack PACK.md -o OUT_DIR
31
31
  codecrate patch OLD_PACK.md ROOT [-o patch.md]
32
32
  codecrate apply PATCH.md ROOT
@@ -36,11 +36,14 @@ Overview
36
36
  pack
37
37
  ----
38
38
 
39
- Create a packed Markdown context file from a repository.
39
+ Create a packed Markdown context file from one or more repositories.
40
40
 
41
41
  .. code-block:: console
42
42
 
43
43
  codecrate pack . -o context.md
44
+ codecrate pack --repo /path/to/repo1 --repo /path/to/repo2 -o multi.md
45
+
46
+ When using ``--repo``, omit the positional ``ROOT``. Specifying both is an error.
44
47
 
45
48
  Useful flags:
46
49
 
@@ -52,7 +55,8 @@ Useful flags:
52
55
  * ``--include GLOB`` (repeatable): include patterns
53
56
  * ``--exclude GLOB`` (repeatable): exclude patterns
54
57
  * ``--split-max-chars N``: additionally emit ``.partN.md`` files for LLMs (the
55
- main output stays
58
+ main output stays unsplit). For multi-repo packs, parts are named
59
+ ``output.<repo>.partN.md``
56
60
  * ``-o/--output PATH``: output path (defaults to config ``output`` or ``context.md``)
57
61
 
58
62
 
@@ -107,7 +111,7 @@ Overview
107
111
 
108
112
  .. code-block:: console
109
113
 
110
- codecrate pack ROOT [options]
114
+ codecrate pack [ROOT] [--repo REPO ...] [options]
111
115
  codecrate unpack PACK.md -o OUT_DIR
112
116
  codecrate patch OLD_PACK.md ROOT [-o patch.md]
113
117
  codecrate apply PATCH.md ROOT
@@ -117,11 +121,14 @@ Overview
117
121
  pack
118
122
  ----
119
123
 
120
- Create a packed Markdown context file from a repository.
124
+ Create a packed Markdown context file from one or more repositories.
121
125
 
122
126
  .. code-block:: console
123
127
 
124
128
  codecrate pack . -o context.md
129
+ codecrate pack --repo /path/to/repo1 --repo /path/to/repo2 -o multi.md
130
+
131
+ When using ``--repo``, omit the positional ``ROOT``. Specifying both is an error.
125
132
 
126
133
  Useful flags:
127
134
 
@@ -131,7 +138,9 @@ Useful flags:
131
138
  * ``--respect-gitignore / --no-respect-gitignore``: include ignored files or not
132
139
  * ``--include GLOB`` (repeatable): include patterns
133
140
  * ``--exclude GLOB`` (repeatable): exclude patterns
134
- * ``--split-max-chars N``: split output into parts
141
+ * ``--split-max-chars N``: additionally emit ``.partN.md`` files for LLMs (the
142
+ main output stays unsplit). For multi-repo packs, parts are named
143
+ ``output.<repo>.partN.md``
135
144
 
136
145
 
137
146
  unpack
@@ -41,6 +41,12 @@ Pack a repository into ``context.md``:
41
41
 
42
42
  codecrate pack /path/to/repo -o context.md
43
43
 
44
+ Pack multiple repositories into a single output:
45
+
46
+ .. code-block:: console
47
+
48
+ codecrate pack --repo /path/to/repo1 --repo /path/to/repo2 -o multi.md
49
+
44
50
  Common options:
45
51
 
46
52
  * ``--dedupe``: deduplicate identical function bodies (enables stub layout when effective)
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from codecrate.cli import main
8
+
9
+
10
+ def _write_repo(root: Path, filename: str, content: str) -> None:
11
+ root.mkdir()
12
+ (root / filename).write_text(content, encoding="utf-8")
13
+
14
+
15
+ def test_pack_multi_repos(tmp_path: Path) -> None:
16
+ repo1 = tmp_path / "repo1"
17
+ repo2 = tmp_path / "repo2"
18
+ _write_repo(repo1, "a.py", "def alpha():\n return 1\n")
19
+ _write_repo(repo2, "b.py", "def beta():\n return 2\n")
20
+
21
+ out_path = tmp_path / "multi.md"
22
+ main(["pack", "--repo", str(repo1), "--repo", str(repo2), "-o", str(out_path)])
23
+
24
+ text = out_path.read_text(encoding="utf-8")
25
+ assert "# Repository: repo1" in text
26
+ assert "# Repository: repo2" in text
27
+ assert "def alpha()" in text
28
+ assert "def beta()" in text
29
+
30
+
31
+ def test_pack_rejects_root_and_repo(tmp_path: Path) -> None:
32
+ repo = tmp_path / "repo"
33
+ _write_repo(repo, "a.py", "def alpha():\n return 1\n")
34
+
35
+ out_path = tmp_path / "multi.md"
36
+ with pytest.raises(SystemExit) as excinfo:
37
+ main(["pack", str(repo), "--repo", str(repo), "-o", str(out_path)])
38
+
39
+ assert excinfo.value.code == 2
@@ -1,250 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import argparse
4
- from pathlib import Path
5
-
6
- from .config import load_config
7
- from .diffgen import generate_patch_markdown
8
- from .discover import discover_files
9
- from .markdown import render_markdown
10
- from .packer import pack_repo
11
- from .token_budget import split_by_max_chars
12
- from .udiff import apply_file_diffs, parse_unified_diff
13
- from .unpacker import unpack_to_dir
14
- from .validate import validate_pack_markdown
15
-
16
-
17
- def build_parser() -> argparse.ArgumentParser:
18
- p = argparse.ArgumentParser(
19
- prog="codecrate",
20
- description="Pack/unpack/patch/apply for repositories (Python + text files).",
21
- )
22
- sub = p.add_subparsers(dest="cmd", required=True)
23
-
24
- # pack
25
- pack = sub.add_parser("pack", help="Pack a repository/directory into Markdown.")
26
- pack.add_argument("root", type=Path, help="Root directory to scan")
27
- pack.add_argument(
28
- "-o",
29
- "--output",
30
- type=Path,
31
- default=None,
32
- help="Output markdown path (default: config 'output' or context.md)",
33
- )
34
- pack.add_argument(
35
- "--dedupe", action="store_true", help="Deduplicate identical function bodies"
36
- )
37
- pack.add_argument(
38
- "--layout",
39
- choices=["auto", "stubs", "full"],
40
- default=None,
41
- help="Output layout: auto|stubs|full (default: auto via config)",
42
- )
43
- pack.add_argument(
44
- "--keep-docstrings",
45
- action=argparse.BooleanOptionalAction,
46
- default=None,
47
- help="Keep docstrings in stubbed file view (default: true via config)",
48
- )
49
- pack.add_argument(
50
- "--respect-gitignore",
51
- action=argparse.BooleanOptionalAction,
52
- default=None,
53
- help="Respect .gitignore (default: true via config)",
54
- )
55
- pack.add_argument(
56
- "--manifest",
57
- action=argparse.BooleanOptionalAction,
58
- default=None,
59
- help="Include Manifest section (default: true via config)",
60
- )
61
- pack.add_argument(
62
- "--include", action="append", default=None, help="Include glob (repeatable)"
63
- )
64
- pack.add_argument(
65
- "--exclude", action="append", default=None, help="Exclude glob (repeatable)"
66
- )
67
- pack.add_argument(
68
- "--split-max-chars",
69
- type=int,
70
- default=None,
71
- help="Split output into .partN.md files",
72
- )
73
-
74
- # unpack
75
- unpack = sub.add_parser(
76
- "unpack", help="Reconstruct files from a packed context Markdown."
77
- )
78
- unpack.add_argument("markdown", type=Path, help="Packed Markdown file from `pack`")
79
- unpack.add_argument(
80
- "-o",
81
- "--out-dir",
82
- type=Path,
83
- required=True,
84
- help="Output directory for reconstructed files",
85
- )
86
-
87
- # patch
88
- patch = sub.add_parser(
89
- "patch",
90
- help="Generate a diff-only patch Markdown from old pack + current repo.",
91
- )
92
- patch.add_argument(
93
- "old_markdown", type=Path, help="Older packed Markdown (baseline)"
94
- )
95
- patch.add_argument("root", type=Path, help="Current repo root to compare against")
96
- patch.add_argument(
97
- "-o",
98
- "--output",
99
- type=Path,
100
- default=Path("patch.md"),
101
- help="Output patch markdown",
102
- )
103
-
104
- # apply
105
- apply = sub.add_parser("apply", help="Apply a diff-only patch Markdown to a repo.")
106
- apply.add_argument(
107
- "patch_markdown", type=Path, help="Patch Markdown containing ```diff blocks"
108
- )
109
- apply.add_argument("root", type=Path, help="Repo root to apply patch to")
110
- # validate-pack
111
- vpack = sub.add_parser(
112
- "validate-pack",
113
- help="Validate a packed context Markdown (sha/markers/canonical consistency).",
114
- )
115
- vpack.add_argument("markdown", type=Path, help="Packed Markdown to validate")
116
- vpack.add_argument(
117
- "--root",
118
- type=Path,
119
- default=None,
120
- help="Optional repo root to compare reconstructed files against",
121
- )
122
-
123
- return p
124
-
125
-
126
- def _extract_diff_blocks(md_text: str) -> str:
127
- """
128
- Extract only diff fences from markdown and concatenate to a unified diff string.
129
- """
130
- lines = md_text.splitlines()
131
- out: list[str] = []
132
- i = 0
133
- while i < len(lines):
134
- if lines[i].strip() == "```diff":
135
- i += 1
136
- while i < len(lines) and lines[i].strip() != "```":
137
- out.append(lines[i])
138
- i += 1
139
- i += 1
140
- return "\n".join(out) + "\n"
141
-
142
-
143
- def main(argv: list[str] | None = None) -> None:
144
- parser = build_parser()
145
- args = parser.parse_args(argv)
146
-
147
- if args.cmd == "pack":
148
- root: Path = args.root.resolve()
149
- cfg = load_config(root)
150
-
151
- include = args.include if args.include is not None else cfg.include
152
- exclude = args.exclude if args.exclude is not None else cfg.exclude
153
-
154
- keep_docstrings = (
155
- cfg.keep_docstrings
156
- if args.keep_docstrings is None
157
- else bool(args.keep_docstrings)
158
- )
159
- include_manifest = (
160
- cfg.manifest if args.manifest is None else bool(args.manifest)
161
- )
162
- respect_gitignore = (
163
- cfg.respect_gitignore
164
- if args.respect_gitignore is None
165
- else bool(args.respect_gitignore)
166
- )
167
- dedupe = bool(args.dedupe) or bool(cfg.dedupe)
168
- split_max_chars = (
169
- cfg.split_max_chars
170
- if args.split_max_chars is None
171
- else int(args.split_max_chars or 0)
172
- )
173
- layout = (
174
- str(args.layout).strip().lower()
175
- if args.layout is not None
176
- else str(getattr(cfg, "layout", "auto")).strip().lower()
177
- )
178
- out_path = (
179
- args.output
180
- if args.output is not None
181
- else Path(getattr(cfg, "output", "context.md"))
182
- )
183
- disc = discover_files(
184
- root=root,
185
- include=include,
186
- exclude=exclude,
187
- respect_gitignore=respect_gitignore,
188
- )
189
- pack, canonical = pack_repo(
190
- disc.root, disc.files, keep_docstrings=keep_docstrings, dedupe=dedupe
191
- )
192
- md = render_markdown(
193
- pack, canonical, layout=layout, include_manifest=include_manifest
194
- )
195
- # Always write the canonical, unsplit pack
196
- # for machine parsing (unpack/validate).
197
- out_path.write_text(md, encoding="utf-8")
198
-
199
- # Additionally, write split parts for LLM consumption, if requested.
200
- parts = split_by_max_chars(md, out_path, split_max_chars)
201
- extra = [p for p in parts if p.path != out_path]
202
- for part in extra:
203
- part.path.write_text(part.content, encoding="utf-8")
204
-
205
- if extra:
206
- print(f"Wrote {out_path} and {len(extra)} split part file(s).")
207
- else:
208
- print(f"Wrote {out_path}.")
209
- elif args.cmd == "unpack":
210
- md_text = args.markdown.read_text(encoding="utf-8", errors="replace")
211
- unpack_to_dir(md_text, args.out_dir)
212
- print(f"Unpacked into {args.out_dir}")
213
-
214
- elif args.cmd == "patch":
215
- old_md = args.old_markdown.read_text(encoding="utf-8", errors="replace")
216
- cfg = load_config(args.root)
217
- patch_md = generate_patch_markdown(
218
- old_md,
219
- args.root,
220
- include=cfg.include,
221
- exclude=cfg.exclude,
222
- respect_gitignore=cfg.respect_gitignore,
223
- )
224
- args.output.write_text(patch_md, encoding="utf-8")
225
- print(f"Wrote {args.output}")
226
-
227
- elif args.cmd == "validate-pack":
228
- md_text = args.markdown.read_text(encoding="utf-8", errors="replace")
229
- report = validate_pack_markdown(md_text, root=args.root)
230
- if report.warnings:
231
- print("Warnings:")
232
- for w in report.warnings:
233
- print(f"- {w}")
234
- if report.errors:
235
- print("Errors:")
236
- for e in report.errors:
237
- print(f"- {e}")
238
- raise SystemExit(1)
239
- print("OK: pack is internally consistent.")
240
-
241
- elif args.cmd == "apply":
242
- md_text = args.patch_markdown.read_text(encoding="utf-8", errors="replace")
243
- diff_text = _extract_diff_blocks(md_text)
244
- diffs = parse_unified_diff(diff_text)
245
- changed = apply_file_diffs(diffs, args.root)
246
- print(f"Applied patch to {len(changed)} file(s).")
247
-
248
-
249
- if __name__ == "__main__":
250
- main()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes