from __future__ import annotationsimport argparseimport osfrom collections import Counterfrom pathlib import PathIGNORE_DIRS = { ".git", ".idea", ".vscode", "node_modules", "dist", "build", "coverage", "__pycache__", ".venv", "venv",}SENSITIVE_NAMES = { ".env", ".env.local", ".env.production", "id_rsa", "id_ed25519", "credentials.json", "secrets.json",}ALLOWED_SUFFIXES = { ".py", ".js", ".jsx", ".ts", ".tsx", ".java", ".go", ".rs", ".php", ".vue", ".sql", ".md", ".json", ".yaml", ".yml", ".toml",}def collect_files( root: Path, max_bytes: int, excluded: set[Path] | None = None,) -> tuple[list[tuple[Path, int]], Counter[str]]: files: list[tuple[Path, int]] = [] skipped: Counter[str] = Counter() excluded = excluded or set() for current_dir, dir_names, file_names in os.walk( root, followlinks=False, ): dir_names[:] = sorted( name for name in dir_names if name not in IGNORE_DIRS and not name.startswith(".") ) current = Path(current_dir) for name in sorted(file_names): path = current / name if path.resolve() in excluded: skipped["output"] += 1 continue if name in SENSITIVE_NAMES or name.startswith(".env."): skipped["sensitive"] += 1 continue if path.is_symlink(): skipped["symlink"] += 1 continue if path.suffix.lower() not in ALLOWED_SUFFIXES: skipped["unsupported"] += 1 continue try: size = path.stat().st_size except OSError: skipped["unreadable"] += 1 continue if size > max_bytes: skipped["too_large"] += 1 continue files.append((path.relative_to(root), size)) return files, skippeddef build_report( root: Path, files: list[tuple[Path, int]], skipped: Counter[str],) -> str: suffix_counts = Counter( path.suffix.lower() or "[no suffix]" for path, _ in files ) lines = [ "# Repository Context", "", f"- Root: `{root.name}`", f"- Included files: {len(files)}", f"- Skipped files: {sum(skipped.values())}", "", "## File types", "", ] if suffix_counts: lines.extend( f"- `{suffix}`: {count}" for suffix, count in sorted(suffix_counts.items()) ) else: lines.append("- No matching files") lines.extend(["", "## Files", ""]) if files: lines.extend( f"- `{path.as_posix()}` ({size} bytes)" for path, size in files ) else: lines.append("- No matching files") lines.extend(["", "## Skip summary", ""]) if skipped: lines.extend( f"- `{reason}`: {count}" for reason, count in sorted(skipped.items()) ) else: lines.append("- Nothing skipped") return "\n".join(lines) + "\n"def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Generate a safe repository context manifest." ) parser.add_argument( "root", type=Path, help="Project root directory", ) parser.add_argument( "-o", "--output", type=Path, default=Path("REPO_CONTEXT.md"), ) parser.add_argument( "--max-kb", type=int, default=200, help="Maximum size per file", ) return parser.parse_args()def main() -> int: args = parse_args() root = args.root.expanduser().resolve() if not root.is_dir(): raise SystemExit( f"Project directory does not exist: {root}" ) if args.max_kb <= 0: raise SystemExit( "--max-kb must be greater than 0" ) output = args.output.expanduser().resolve() files, skipped = collect_files( root, args.max_kb * 1024, excluded={output}, ) report = build_report(root, files, skipped) output.write_text(report, encoding="utf-8") print(f"Wrote {len(files)} files to {output}") return 0if __name__ == "__main__": raise SystemExit(main())