RepoPackPy 0.1.1__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.
- repopack/__init__.py +1 -0
- repopack/cli.py +58 -0
- repopack/mcp_server.py +41 -0
- repopack/packer.py +96 -0
- repopack/unpacker.py +60 -0
- repopack/utils.py +183 -0
- repopackpy-0.1.1.dist-info/METADATA +126 -0
- repopackpy-0.1.1.dist-info/RECORD +11 -0
- repopackpy-0.1.1.dist-info/WHEEL +4 -0
- repopackpy-0.1.1.dist-info/entry_points.txt +2 -0
- repopackpy-0.1.1.dist-info/licenses/LICENSE +21 -0
repopack/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.1"
|
repopack/cli.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import typer
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from repopack.packer import pack_workspace
|
|
6
|
+
from repopack.unpacker import unpack_workspace
|
|
7
|
+
from repopack.mcp_server import run_server
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(name="repopack", help="Pack/unpack any workspace into portable JSON.")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.command()
|
|
13
|
+
def pack(
|
|
14
|
+
directory: str = typer.Argument(".", help="The workspace to pack."),
|
|
15
|
+
output: str = typer.Option(None, "-o", "--output", help="Output JSON file path."),
|
|
16
|
+
include_binary: bool = typer.Option(False, "--include-binary", help="Include binary files."),
|
|
17
|
+
custom_ignore: str = typer.Option(None, "--custom-ignore", help="Comma-separated extra ignore patterns."),
|
|
18
|
+
):
|
|
19
|
+
try:
|
|
20
|
+
result = pack_workspace(Path(directory), include_binary, custom_ignore)
|
|
21
|
+
if output:
|
|
22
|
+
with open(output, "w", encoding="utf-8") as f:
|
|
23
|
+
json.dump(result, f, indent=2)
|
|
24
|
+
total_files = result.get("total_files", 0)
|
|
25
|
+
total_bytes = result.get("total_bytes", 0)
|
|
26
|
+
typer.echo(f"Packed {total_files} files ({total_bytes} bytes) → {output}", err=True)
|
|
27
|
+
else:
|
|
28
|
+
print(json.dumps(result, indent=2))
|
|
29
|
+
except Exception as e:
|
|
30
|
+
typer.echo(str(e), err=True)
|
|
31
|
+
raise typer.Exit(1)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.command()
|
|
35
|
+
def unpack(
|
|
36
|
+
input: str = typer.Argument(..., help="File path or raw JSON string to unpack."),
|
|
37
|
+
target: str = typer.Option(".", "-t", "--target", help="Output directory."),
|
|
38
|
+
force: bool = typer.Option(False, "--force/--no-force", help="Overwrite existing files."),
|
|
39
|
+
):
|
|
40
|
+
try:
|
|
41
|
+
result = unpack_workspace(input, Path(target), overwrite=force)
|
|
42
|
+
extracted = result.get("extracted", 0)
|
|
43
|
+
destination = result.get("destination", target)
|
|
44
|
+
skipped = result.get("skipped", 0)
|
|
45
|
+
typer.echo(f"Extracted {extracted} files to {destination} ({skipped} skipped)")
|
|
46
|
+
except Exception as e:
|
|
47
|
+
typer.echo(str(e), err=True)
|
|
48
|
+
raise typer.Exit(1)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.command()
|
|
52
|
+
def serve():
|
|
53
|
+
typer.echo("Starting RepoPack MCP server (stdio)...", err=True)
|
|
54
|
+
run_server()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main():
|
|
58
|
+
app()
|
repopack/mcp_server.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from mcp.server.fastmcp import FastMCP
|
|
5
|
+
|
|
6
|
+
from repopack.packer import pack_workspace
|
|
7
|
+
from repopack.unpacker import unpack_workspace
|
|
8
|
+
|
|
9
|
+
mcp = FastMCP("RepoPack")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@mcp.tool()
|
|
13
|
+
def export_workspace(
|
|
14
|
+
workspace_path: str = ".",
|
|
15
|
+
output_json_path: str | None = None,
|
|
16
|
+
include_binary: bool = False,
|
|
17
|
+
) -> str:
|
|
18
|
+
result = pack_workspace(Path(workspace_path), include_binary=include_binary)
|
|
19
|
+
if output_json_path is not None:
|
|
20
|
+
with open(output_json_path, "w", encoding="utf-8") as f:
|
|
21
|
+
json.dump(result, f, indent=2)
|
|
22
|
+
return f"Packed {result['metadata']['total_files']} files ({result['metadata']['total_bytes']} bytes) to {output_json_path}"
|
|
23
|
+
return json.dumps(result, indent=2)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@mcp.tool()
|
|
27
|
+
def import_workspace(
|
|
28
|
+
json_input: str,
|
|
29
|
+
destination_path: str,
|
|
30
|
+
overwrite: bool = False,
|
|
31
|
+
) -> str:
|
|
32
|
+
summary = unpack_workspace(json_input, Path(destination_path), overwrite=overwrite)
|
|
33
|
+
return f"Extracted {summary['extracted']} files to {summary['destination']} ({summary['skipped']} skipped)"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run_server():
|
|
37
|
+
mcp.run(transport="stdio")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
if __name__ == "__main__":
|
|
41
|
+
run_server()
|
repopack/packer.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import datetime
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from repopack.utils import detect_stack, is_binary_file, is_ignored, load_gitignore_specs
|
|
8
|
+
|
|
9
|
+
_MAX_FILE_BYTES = 5 * 1024 * 1024
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def pack_workspace(
|
|
13
|
+
directory: Path,
|
|
14
|
+
include_binary: bool = False,
|
|
15
|
+
custom_ignore: str | None = None,
|
|
16
|
+
) -> dict:
|
|
17
|
+
directory = directory.resolve()
|
|
18
|
+
|
|
19
|
+
specs = load_gitignore_specs(directory)
|
|
20
|
+
|
|
21
|
+
custom_patterns: list[str] = []
|
|
22
|
+
if custom_ignore:
|
|
23
|
+
custom_patterns = [p.strip() for p in custom_ignore.split(",") if p.strip()]
|
|
24
|
+
|
|
25
|
+
files: list[dict] = []
|
|
26
|
+
total_bytes: int = 0
|
|
27
|
+
|
|
28
|
+
for root, dirs, filenames in os.walk(directory, topdown=True):
|
|
29
|
+
root_path = Path(root)
|
|
30
|
+
|
|
31
|
+
dirs[:] = [
|
|
32
|
+
d for d in dirs
|
|
33
|
+
if d not in {".git", ".svn", ".hg"}
|
|
34
|
+
and not is_ignored(root_path / d, directory, specs, custom_patterns)
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
for filename in filenames:
|
|
38
|
+
file_path = root_path / filename
|
|
39
|
+
rel_path = file_path.relative_to(directory)
|
|
40
|
+
|
|
41
|
+
if is_ignored(file_path, directory, specs, custom_patterns):
|
|
42
|
+
continue
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
file_size = file_path.stat().st_size
|
|
46
|
+
except OSError:
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
if file_size > _MAX_FILE_BYTES:
|
|
50
|
+
print(
|
|
51
|
+
f"warning: skipping {rel_path.as_posix()} ({file_size} bytes > 5 MB)",
|
|
52
|
+
file=sys.stderr,
|
|
53
|
+
)
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
binary = is_binary_file(file_path)
|
|
57
|
+
|
|
58
|
+
if binary and not include_binary:
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
if binary:
|
|
62
|
+
try:
|
|
63
|
+
raw = file_path.read_bytes()
|
|
64
|
+
except OSError:
|
|
65
|
+
continue
|
|
66
|
+
content = base64.b64encode(raw).decode("ascii")
|
|
67
|
+
encoding = "base64"
|
|
68
|
+
else:
|
|
69
|
+
try:
|
|
70
|
+
content = file_path.read_text(encoding="utf-8", errors="replace")
|
|
71
|
+
except OSError:
|
|
72
|
+
continue
|
|
73
|
+
encoding = "utf-8"
|
|
74
|
+
|
|
75
|
+
total_bytes += file_size
|
|
76
|
+
files.append(
|
|
77
|
+
{
|
|
78
|
+
"path": rel_path.as_posix(),
|
|
79
|
+
"encoding": encoding,
|
|
80
|
+
"content": content,
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
detected_stack = detect_stack(directory)
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
"version": "1.0",
|
|
88
|
+
"metadata": {
|
|
89
|
+
"created_at": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
90
|
+
"root_directory_name": directory.name,
|
|
91
|
+
"detected_stack": detected_stack,
|
|
92
|
+
"total_files": len(files),
|
|
93
|
+
"total_bytes": total_bytes,
|
|
94
|
+
},
|
|
95
|
+
"files": files,
|
|
96
|
+
}
|
repopack/unpacker.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from repopack.utils import safe_relative_path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def unpack_workspace(
|
|
10
|
+
json_input: str,
|
|
11
|
+
destination_path: Path,
|
|
12
|
+
overwrite: bool = False,
|
|
13
|
+
force: bool = False,
|
|
14
|
+
) -> dict:
|
|
15
|
+
if Path(json_input).is_file():
|
|
16
|
+
with open(json_input, "r", encoding="utf-8") as f:
|
|
17
|
+
data = json.load(f)
|
|
18
|
+
else:
|
|
19
|
+
data = json.loads(json_input)
|
|
20
|
+
|
|
21
|
+
if "version" not in data or "files" not in data:
|
|
22
|
+
raise ValueError("Malformed input: top-level JSON must contain 'version' and 'files' keys.")
|
|
23
|
+
|
|
24
|
+
destination_path = destination_path.resolve()
|
|
25
|
+
|
|
26
|
+
extracted = 0
|
|
27
|
+
skipped = 0
|
|
28
|
+
|
|
29
|
+
for entry in data["files"]:
|
|
30
|
+
rel = entry.get("path", "")
|
|
31
|
+
|
|
32
|
+
rel_path = Path(rel)
|
|
33
|
+
if rel_path.is_absolute():
|
|
34
|
+
raise ValueError(f"Path traversal detected: {rel}")
|
|
35
|
+
if any(part == ".." for part in rel_path.parts):
|
|
36
|
+
raise ValueError(f"Path traversal detected: {rel}")
|
|
37
|
+
|
|
38
|
+
resolved = (destination_path / rel_path).resolve()
|
|
39
|
+
if os.path.commonpath([str(destination_path), str(resolved)]) != str(destination_path):
|
|
40
|
+
raise ValueError(f"Path traversal detected: {rel}")
|
|
41
|
+
|
|
42
|
+
output_path = destination_path / rel_path
|
|
43
|
+
|
|
44
|
+
if output_path.exists() and not overwrite:
|
|
45
|
+
skipped += 1
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
|
|
50
|
+
encoding = entry.get("encoding", "utf-8")
|
|
51
|
+
content = entry.get("content", "")
|
|
52
|
+
|
|
53
|
+
if encoding == "base64":
|
|
54
|
+
output_path.write_bytes(base64.b64decode(content))
|
|
55
|
+
else:
|
|
56
|
+
output_path.write_text(content, encoding="utf-8")
|
|
57
|
+
|
|
58
|
+
extracted += 1
|
|
59
|
+
|
|
60
|
+
return {"extracted": extracted, "skipped": skipped, "destination": str(destination_path)}
|
repopack/utils.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pathspec
|
|
7
|
+
|
|
8
|
+
DEFAULT_IGNORE_PATTERNS: list[str] = [
|
|
9
|
+
".git/",
|
|
10
|
+
".svn/",
|
|
11
|
+
".hg/",
|
|
12
|
+
".DS_Store",
|
|
13
|
+
"Thumbs.db",
|
|
14
|
+
"node_modules/",
|
|
15
|
+
"dist/",
|
|
16
|
+
"build/",
|
|
17
|
+
".next/",
|
|
18
|
+
".nuxt/",
|
|
19
|
+
".angular/",
|
|
20
|
+
".cache/",
|
|
21
|
+
"coverage/",
|
|
22
|
+
".astro/",
|
|
23
|
+
".turbo/",
|
|
24
|
+
"out/",
|
|
25
|
+
"__pycache__/",
|
|
26
|
+
"*.pyc",
|
|
27
|
+
".venv/",
|
|
28
|
+
"venv/",
|
|
29
|
+
".pytest_cache/",
|
|
30
|
+
".mypy_cache/",
|
|
31
|
+
".eggs/",
|
|
32
|
+
"*.egg-info/",
|
|
33
|
+
"sdist/",
|
|
34
|
+
"target/",
|
|
35
|
+
"bin/",
|
|
36
|
+
"obj/",
|
|
37
|
+
"*.exe",
|
|
38
|
+
"*.so",
|
|
39
|
+
"*.dylib",
|
|
40
|
+
"*.dll",
|
|
41
|
+
"*.class",
|
|
42
|
+
"*.jar",
|
|
43
|
+
"*.o",
|
|
44
|
+
"*.a",
|
|
45
|
+
".env",
|
|
46
|
+
".env.local",
|
|
47
|
+
".env.*.local",
|
|
48
|
+
"package-lock.json",
|
|
49
|
+
"yarn.lock",
|
|
50
|
+
"pnpm-lock.yaml",
|
|
51
|
+
"poetry.lock",
|
|
52
|
+
"Pipfile.lock",
|
|
53
|
+
"Cargo.lock",
|
|
54
|
+
"go.sum",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
_BINARY_EXTENSIONS: frozenset[str] = frozenset({
|
|
58
|
+
".png", ".jpg", ".jpeg", ".gif", ".ico", ".webp", ".bmp", ".tiff",
|
|
59
|
+
".pdf",
|
|
60
|
+
".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar",
|
|
61
|
+
".woff", ".woff2", ".ttf", ".eot", ".otf",
|
|
62
|
+
".mp3", ".mp4", ".wav", ".ogg", ".avi", ".mov", ".webm",
|
|
63
|
+
".pyc", ".pyo",
|
|
64
|
+
".exe", ".dll", ".so", ".dylib",
|
|
65
|
+
".class", ".jar",
|
|
66
|
+
".o", ".a", ".lib",
|
|
67
|
+
".db", ".sqlite", ".sqlite3",
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load_gitignore_specs(root_dir: Path) -> list[tuple[Path, pathspec.PathSpec]]:
|
|
72
|
+
specs: list[tuple[Path, pathspec.PathSpec]] = []
|
|
73
|
+
for gitignore_path in sorted(root_dir.rglob(".gitignore")):
|
|
74
|
+
lines = gitignore_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
75
|
+
spec = pathspec.PathSpec.from_lines("gitignore", lines)
|
|
76
|
+
specs.append((gitignore_path.parent, spec))
|
|
77
|
+
return specs
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def is_ignored(
|
|
81
|
+
path: Path,
|
|
82
|
+
root_dir: Path,
|
|
83
|
+
gitignore_specs: list[tuple[Path, pathspec.PathSpec]],
|
|
84
|
+
custom_patterns: list[str] | None = None,
|
|
85
|
+
) -> bool:
|
|
86
|
+
if custom_patterns:
|
|
87
|
+
custom_spec = pathspec.PathSpec.from_lines("gitignore", custom_patterns)
|
|
88
|
+
rel = path.relative_to(root_dir)
|
|
89
|
+
if custom_spec.match_file(str(rel)):
|
|
90
|
+
return True
|
|
91
|
+
|
|
92
|
+
for spec_dir, spec in gitignore_specs:
|
|
93
|
+
try:
|
|
94
|
+
rel = path.relative_to(spec_dir)
|
|
95
|
+
except ValueError:
|
|
96
|
+
continue
|
|
97
|
+
if spec.match_file(str(rel)):
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
default_spec = pathspec.PathSpec.from_lines("gitignore", DEFAULT_IGNORE_PATTERNS)
|
|
101
|
+
try:
|
|
102
|
+
rel_from_root = path.relative_to(root_dir)
|
|
103
|
+
except ValueError:
|
|
104
|
+
rel_from_root = path
|
|
105
|
+
if default_spec.match_file(str(rel_from_root)):
|
|
106
|
+
return True
|
|
107
|
+
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def is_binary_file(path: Path) -> bool:
|
|
112
|
+
if path.suffix.lower() in _BINARY_EXTENSIONS:
|
|
113
|
+
return True
|
|
114
|
+
try:
|
|
115
|
+
chunk = path.read_bytes()[:8192]
|
|
116
|
+
except OSError:
|
|
117
|
+
return False
|
|
118
|
+
return b"\x00" in chunk
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def detect_stack(root_dir: Path) -> list[str]:
|
|
122
|
+
entries = {p.name: p for p in root_dir.iterdir()}
|
|
123
|
+
stacks: list[str] = []
|
|
124
|
+
|
|
125
|
+
if "package.json" in entries:
|
|
126
|
+
stacks.append("Node.js")
|
|
127
|
+
try:
|
|
128
|
+
import json
|
|
129
|
+
pkg = json.loads(entries["package.json"].read_text(encoding="utf-8", errors="replace"))
|
|
130
|
+
deps = {
|
|
131
|
+
**pkg.get("dependencies", {}),
|
|
132
|
+
**pkg.get("devDependencies", {}),
|
|
133
|
+
}
|
|
134
|
+
if "react" in deps:
|
|
135
|
+
stacks.append("React")
|
|
136
|
+
except Exception:
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
has_ts = (
|
|
140
|
+
"tsconfig.json" in entries
|
|
141
|
+
or any(p.suffix in {".ts", ".tsx"} for p in root_dir.iterdir() if p.is_file())
|
|
142
|
+
)
|
|
143
|
+
if has_ts:
|
|
144
|
+
stacks.append("TypeScript")
|
|
145
|
+
|
|
146
|
+
if "angular.json" in entries:
|
|
147
|
+
stacks.append("Angular")
|
|
148
|
+
|
|
149
|
+
if any(e.startswith("next.config.") for e in entries):
|
|
150
|
+
stacks.append("Next.js")
|
|
151
|
+
|
|
152
|
+
if any(e.startswith("vue.config.") for e in entries) or any(
|
|
153
|
+
p.suffix == ".vue" for p in root_dir.iterdir() if p.is_file()
|
|
154
|
+
):
|
|
155
|
+
stacks.append("Vue")
|
|
156
|
+
|
|
157
|
+
if any(e in entries for e in ("requirements.txt", "pyproject.toml", "setup.py")):
|
|
158
|
+
stacks.append("Python")
|
|
159
|
+
|
|
160
|
+
if "Cargo.toml" in entries:
|
|
161
|
+
stacks.append("Rust")
|
|
162
|
+
|
|
163
|
+
if "go.mod" in entries:
|
|
164
|
+
stacks.append("Go")
|
|
165
|
+
|
|
166
|
+
if "pom.xml" in entries or "build.gradle" in entries:
|
|
167
|
+
stacks.append("Java")
|
|
168
|
+
|
|
169
|
+
if any(p.suffix in {".csproj", ".sln"} for p in root_dir.iterdir() if p.is_file()):
|
|
170
|
+
stacks.append("C#")
|
|
171
|
+
|
|
172
|
+
return stacks
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def safe_relative_path(base: Path, target: Path) -> Path:
|
|
176
|
+
resolved_base = base.resolve()
|
|
177
|
+
resolved_target = target.resolve()
|
|
178
|
+
common = os.path.commonpath([str(resolved_base), str(resolved_target)])
|
|
179
|
+
if common != str(resolved_base):
|
|
180
|
+
raise ValueError(
|
|
181
|
+
f"Path traversal detected: {target!r} is not under {base!r}"
|
|
182
|
+
)
|
|
183
|
+
return resolved_target.relative_to(resolved_base)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: RepoPackPy
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Pack/unpack any source workspace into portable JSON, with MCP server support.
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: mcp[cli]>=1.0
|
|
9
|
+
Requires-Dist: pathspec>=0.12
|
|
10
|
+
Requires-Dist: typer>=0.12
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# RepoPack
|
|
16
|
+
|
|
17
|
+
Pack/unpack **any source workspace regardless of technology stack** (Python, Node.js, React, Angular, Vue, Rust, Go, Java, C#, and more) into a portable, structured JSON payload — and restore it faithfully from that payload.
|
|
18
|
+
|
|
19
|
+
## Features
|
|
20
|
+
|
|
21
|
+
- **Universal ignore engine** — respects root and nested `.gitignore` files plus stack-agnostic defaults (node_modules, __pycache__, .venv, target/, dist/, secrets, lock files, …)
|
|
22
|
+
- **Polyglot stack detection** — auto-detects Node.js, React, TypeScript, Angular, Next.js, Vue, Python, Rust, Go, Java, C# from project sentinel files
|
|
23
|
+
- **Binary safety** — text files encoded as UTF-8; binaries skipped by default or included as Base64 with `--include-binary`
|
|
24
|
+
- **5 MB guard** — files larger than 5 MB are skipped with a warning
|
|
25
|
+
- **Path traversal shield** — `unpack` validates every path against the destination root before writing anything
|
|
26
|
+
- **MCP server** — expose pack/unpack as tools to any MCP-compatible AI client (Claude Desktop, etc.)
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install RepoPackPy
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
For development:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install -e ".[dev]"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Requires Python 3.10+.
|
|
41
|
+
|
|
42
|
+
## CLI Usage
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# Pack a directory into JSON
|
|
46
|
+
repopack pack [DIRECTORY] [-o output.json] [--include-binary] [--custom-ignore ".next,dist"]
|
|
47
|
+
|
|
48
|
+
# Unpack JSON back into a directory
|
|
49
|
+
repopack unpack <input.json> [-t TARGET_DIR] [--force]
|
|
50
|
+
|
|
51
|
+
# Start the MCP server (stdio transport)
|
|
52
|
+
repopack serve
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Examples
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# Pack current directory, write to file
|
|
59
|
+
repopack pack . -o workspace.json
|
|
60
|
+
|
|
61
|
+
# Pack a specific project, include binaries
|
|
62
|
+
repopack pack ~/projects/my-app -o my-app.json --include-binary
|
|
63
|
+
|
|
64
|
+
# Unpack into a new directory
|
|
65
|
+
repopack unpack workspace.json -t ./restored
|
|
66
|
+
|
|
67
|
+
# Unpack and overwrite existing files
|
|
68
|
+
repopack unpack workspace.json -t ./restored --force
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## JSON Payload Schema
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"version": "1.0",
|
|
76
|
+
"metadata": {
|
|
77
|
+
"created_at": "2026-07-26T12:00:00Z",
|
|
78
|
+
"root_directory_name": "my-app",
|
|
79
|
+
"detected_stack": ["Node.js", "React", "TypeScript"],
|
|
80
|
+
"total_files": 38,
|
|
81
|
+
"total_bytes": 128450
|
|
82
|
+
},
|
|
83
|
+
"files": [
|
|
84
|
+
{ "path": "src/App.tsx", "encoding": "utf-8", "content": "..." },
|
|
85
|
+
{ "path": "public/favicon.ico", "encoding": "base64", "content": "..." }
|
|
86
|
+
]
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## MCP Server Tools
|
|
91
|
+
|
|
92
|
+
When running `repopack serve`, two tools are registered via FastMCP:
|
|
93
|
+
|
|
94
|
+
| Tool | Description |
|
|
95
|
+
|---|---|
|
|
96
|
+
| `export_workspace(workspace_path, output_json_path, include_binary)` | Pack a directory into JSON |
|
|
97
|
+
| `import_workspace(json_input, destination_path, overwrite)` | Unpack JSON into a directory |
|
|
98
|
+
|
|
99
|
+
## Project Structure
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
repopack/
|
|
103
|
+
├── pyproject.toml
|
|
104
|
+
├── README.md
|
|
105
|
+
└── repopack/
|
|
106
|
+
├── __init__.py
|
|
107
|
+
├── cli.py # Typer CLI (pack / unpack / serve)
|
|
108
|
+
├── mcp_server.py # FastMCP server
|
|
109
|
+
├── packer.py # Directory walker & JSON builder
|
|
110
|
+
├── unpacker.py # JSON extractor & path-safe reconstructor
|
|
111
|
+
└── utils.py # Ignore engine, binary detector, stack detection
|
|
112
|
+
tests/
|
|
113
|
+
└── test_repopack.py # 26-test pytest suite
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Running Tests
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
pytest tests/ -v
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Dependencies
|
|
123
|
+
|
|
124
|
+
- [typer](https://typer.tiangolo.com/) — CLI framework
|
|
125
|
+
- [mcp](https://github.com/modelcontextprotocol/python-sdk) — MCP SDK (FastMCP)
|
|
126
|
+
- [pathspec](https://github.com/cpburnz/python-pathspec) — gitignore wildmatch pattern matching
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
repopack/__init__.py,sha256=rnObPjuBcEStqSO0S6gsdS_ot8ITOQjVj_-P1LUUYpg,22
|
|
2
|
+
repopack/cli.py,sha256=iuRVCnGrOPgEEARsFOYahFt2dUS-SmJRYjkhL73DgPQ,2090
|
|
3
|
+
repopack/mcp_server.py,sha256=qX4EougdL8ArnfiRoMWmV1ZSzim1xsD2MUPtdfjzxIs,1166
|
|
4
|
+
repopack/packer.py,sha256=2h4dC-oV2LIPLVUQ0m7WwAaA9mkpJNXfM3CLZ3GEvF0,2761
|
|
5
|
+
repopack/unpacker.py,sha256=RDIXprVPg9ZTcAzN2CgxGq7EbxB2ex9EDojD28TGGK8,1798
|
|
6
|
+
repopack/utils.py,sha256=UnKnSGl_StMlE4OQI1zCNUl--UPV6VFzW3UGTfsi6JI,4807
|
|
7
|
+
repopackpy-0.1.1.dist-info/METADATA,sha256=QkenIk0OVB3rqsXBk1awNAcfpvnBlYQgvE4uSVaTUxs,3783
|
|
8
|
+
repopackpy-0.1.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
9
|
+
repopackpy-0.1.1.dist-info/entry_points.txt,sha256=6_yKH4CqxdMrFaHwQH-bKM1XW0lppiBJ3ioWJ8RFlTs,47
|
|
10
|
+
repopackpy-0.1.1.dist-info/licenses/LICENSE,sha256=Z4Oa85qoUpnHkq4Pa4-LOGiCT5cvJaX5_aP0K8OXBNQ,1069
|
|
11
|
+
repopackpy-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shan Konduru
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|