Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ my-project/.contextzip/upload/
The files can then be selected together and dragged into a ChatGPT conversation or project.

Directory separators become `__`. Extensionless files such as `Dockerfile` receive a `.txt` suffix for web upload compatibility. Only the output filename changes; the file bytes do not.
Long or colliding output names receive a stable hash suffix and stay within a 240-byte filename limit.

## Core principles

Expand Down Expand Up @@ -112,6 +113,8 @@ python3 scripts/pack.py \

Use `--help` for all options.

Custom output and manifest paths are resolved and validated before any files are deleted or written. The output must be a strict subdirectory of `<project>/.contextzip`; the manifest must also remain inside `.contextzip` but outside the output directory. Overlapping paths, paths outside `.contextzip`, and a symlinked `.contextzip` directory are rejected.

## Selection behavior

When the project is a Git repository, ContextZIP uses Git to collect tracked files and unignored untracked files. This respects `.gitignore` without implementing a second ignore parser.
Expand Down
4 changes: 3 additions & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ Create a lightweight upload pack from the current local project.
- Prefer path names, extensions, Git status, `.gitignore`, file sizes, and explicit user scope.
- Copy selected files byte-for-byte into a flat output directory.
- Keep `manifest.json` outside the upload directory so it does not add context unless the user chooses to upload it.
- Keep output and manifest paths inside `<project-root>/.contextzip`; keep the manifest outside the upload directory.
- Use the default paths unless the user explicitly requests safe custom paths inside `.contextzip`.
- Never upload files automatically.
- Never bypass the sensitive-path blocklist unless the user explicitly edits the script themselves.

Expand Down Expand Up @@ -66,7 +68,7 @@ python3 <skill-directory>/scripts/pack.py \
└── manifest.json
```

The flattened filename encodes the original relative path with `__`. Extensionless files receive a `.txt` suffix for web upload compatibility. The manifest records the exact source-to-output mapping and SHA-256 hashes.
The flattened filename encodes the original relative path with `__`. Extensionless files receive a `.txt` suffix for web upload compatibility. Long or colliding names receive a stable hash while remaining within 240 bytes. The manifest records the exact source-to-output mapping and SHA-256 hashes.

## Important interpretation

Expand Down
4 changes: 4 additions & 0 deletions agents/openai.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
interface:
display_name: "ContextZIP"
short_description: "Prepare safe, byte-preserving project upload packs"
default_prompt: "Use $contextzip to prepare the current project as a safe drag-and-drop upload pack."
117 changes: 85 additions & 32 deletions scripts/pack.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,41 +357,53 @@ def select_candidates(
return selected, skipped


def truncate_utf8(value: str, max_bytes: int) -> str:
"""Truncate text to a UTF-8 byte budget without splitting a character."""
return value.encode("utf-8")[:max_bytes].decode("utf-8", errors="ignore")


def fit_flat_name(candidate: str, marker: str = "") -> str:
"""Fit a filename and optional pre-extension marker within the byte limit."""
suffix = Path(candidate).suffix
stem = candidate[: -len(suffix)] if suffix else candidate
reserve = f"{marker}{suffix}"

if len(reserve.encode("utf-8")) >= MAX_OUTPUT_FILENAME_BYTES:
stem = candidate
suffix = ""
reserve = marker

stem_budget = MAX_OUTPUT_FILENAME_BYTES - len(reserve.encode("utf-8"))
fitted = f"{truncate_utf8(stem, stem_budget)}{marker}{suffix}"
if len(fitted.encode("utf-8")) > MAX_OUTPUT_FILENAME_BYTES:
raise PackError("Could not create a safe output filename")
return fitted


def safe_flat_name(relative: Path, used_names: set[str]) -> str:
"""Encode a relative path as one visible, collision-safe filename."""
parts = []
for part in relative.parts:
visible = f"dot_{part[1:]}" if part.startswith(".") else part
parts.append(visible)
candidate = "__".join(parts)
base_candidate = "__".join(parts)
if not relative.suffix:
candidate = f"{candidate}.txt"
base_candidate = f"{base_candidate}.txt"

digest = hashlib.sha256(relative.as_posix().encode("utf-8")).hexdigest()[:10]
encoded = candidate.encode("utf-8")
if len(encoded) > MAX_OUTPUT_FILENAME_BYTES:
suffix = Path(candidate).suffix
reserve = len((f"--{digest}{suffix}").encode("utf-8"))
budget = max(1, MAX_OUTPUT_FILENAME_BYTES - reserve)
prefix_bytes = candidate[: -len(suffix) if suffix else None].encode("utf-8")[:budget]
prefix = prefix_bytes.decode("utf-8", errors="ignore")
candidate = f"{prefix}--{digest}{suffix}"

if candidate in used_names:
suffix = Path(candidate).suffix
stem = candidate[: -len(suffix)] if suffix else candidate
candidate = f"{stem}--{digest}{suffix}"

counter = 2
unique_candidate = candidate
while unique_candidate in used_names:
suffix = Path(candidate).suffix
stem = candidate[: -len(suffix)] if suffix else candidate
unique_candidate = f"{stem}-{counter}{suffix}"
if len(base_candidate.encode("utf-8")) <= MAX_OUTPUT_FILENAME_BYTES:
candidate = base_candidate
else:
candidate = fit_flat_name(base_candidate, f"--{digest}")

counter = 1
while candidate in used_names:
marker = f"--{digest}" if counter == 1 else f"--{digest}-{counter}"
candidate = fit_flat_name(base_candidate, marker)
counter += 1

used_names.add(unique_candidate)
return unique_candidate
used_names.add(candidate)
return candidate


def sha256_file(path: Path) -> str:
Expand All @@ -402,15 +414,46 @@ def sha256_file(path: Path) -> str:
return digest.hexdigest()


def ensure_safe_output(root: Path, output: Path) -> None:
def ensure_safe_paths(root: Path, output: Path, manifest_path: Path) -> None:
root_resolved = root.resolve()
contextzip_path = root_resolved / ".contextzip"
if contextzip_path.is_symlink():
raise PackError("<project>/.contextzip must not be a symlink")

contextzip_resolved = contextzip_path.resolve()
output_resolved = output.resolve()
manifest_resolved = manifest_path.resolve()

try:
relative = output_resolved.relative_to(root_resolved)
output_relative = output_resolved.relative_to(contextzip_resolved)
except ValueError as exc:
raise PackError("Output directory must be inside the project root") from exc
if not relative.parts or relative.parts[0] != ".contextzip":
raise PackError("Output directory must be inside <project>/.contextzip")
raise PackError("Output directory must be inside <project>/.contextzip") from exc
if not output_relative.parts:
raise PackError("Output directory must be a strict subdirectory of <project>/.contextzip")

try:
manifest_relative = manifest_resolved.relative_to(contextzip_resolved)
except ValueError as exc:
raise PackError("Manifest path must be inside <project>/.contextzip") from exc
if not manifest_relative.parts:
raise PackError("Manifest path must name a file inside <project>/.contextzip")

try:
manifest_resolved.relative_to(output_resolved)
except ValueError:
pass
else:
raise PackError("Manifest path must be outside the upload directory")

try:
output_resolved.relative_to(manifest_resolved)
except ValueError:
pass
else:
raise PackError("Manifest path must not contain the upload directory")

if manifest_resolved.is_dir():
raise PackError("Manifest path must not be a directory")


def prepare_output(output: Path) -> None:
Expand All @@ -428,7 +471,10 @@ def create_pack(
mode: str,
verify: bool,
) -> dict[str, object]:
ensure_safe_output(root, output)
root = root.resolve()
output = output.resolve()
manifest_path = manifest_path.resolve()
ensure_safe_paths(root, output, manifest_path)
prepare_output(output)
manifest_path.parent.mkdir(parents=True, exist_ok=True)

Expand Down Expand Up @@ -511,13 +557,19 @@ def build_parser() -> argparse.ArgumentParser:
"--output",
type=Path,
default=None,
help=f"Output directory relative to root. Defaults to {DEFAULT_OUTPUT_DIR}.",
help=(
"Output directory inside <root>/.contextzip. "
f"Defaults to {DEFAULT_OUTPUT_DIR}."
),
)
parser.add_argument(
"--manifest",
type=Path,
default=None,
help=f"Manifest path relative to root. Defaults to {DEFAULT_MANIFEST}.",
help=(
"Manifest path inside <root>/.contextzip and outside the output directory. "
f"Defaults to {DEFAULT_MANIFEST}."
),
)
parser.add_argument("--include", action="append", default=[], help="Additional include glob. Repeatable.")
parser.add_argument("--exclude", action="append", default=[], help="Exclude glob. Repeatable.")
Expand Down Expand Up @@ -575,6 +627,7 @@ def main(argv: Sequence[str] | None = None) -> int:
max_file_size_bytes = int(args.max_file_size_mb * 1024 * 1024)

try:
ensure_safe_paths(root, output, manifest)
selected, skipped = select_candidates(
root=root,
mode=args.mode,
Expand Down
Loading
Loading