Add bundle-zip skill

This commit is contained in:
wantsong 2026-06-18 01:44:16 +08:00
parent 86d860044b
commit 287ef86eca
12 changed files with 606 additions and 5 deletions

View File

@ -21,12 +21,14 @@ This repository includes install scripts for copying reviewed Skill source into
PowerShell: PowerShell:
```powershell ```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill bundle-zip
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill repair-markdown-citations powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill repair-markdown-citations
``` ```
Use `-Force` to back up and replace an existing installed copy: Use `-Force` to back up and replace an existing installed copy:
```powershell ```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill bundle-zip -Force
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill repair-markdown-citations -Force powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill repair-markdown-citations -Force
``` ```

View File

@ -47,20 +47,33 @@ conda activate C:\Users\wangq\.conda\envs\skills-vault
Most lightweight Skills should use this shared environment. Create a dedicated environment only when a Skill has conflicting or heavy dependencies. Most lightweight Skills should use this shared environment. Create a dedicated environment only when a Skill has conflicting or heavy dependencies.
The shared environment includes `pyyaml` so Skill metadata validation can also run inside the repository environment. Do not fall back to bare `python` for validation just because the base Miniconda environment happens to have extra packages.
When running Python from automation, prefer `conda run` so the intended environment is explicit even if the shell has not been activated: When running Python from automation, prefer `conda run` so the intended environment is explicit even if the shell has not been activated:
```powershell ```powershell
conda run -n skills-vault python -m unittest discover -s skills\fix-title\tests -v conda run -n skills-vault python -B -m unittest discover -s skills\fix-title\tests -v
conda run -n skills-vault python .\skills\fix-title\scripts\fix_markdown_titles.py --help conda run -n skills-vault python -B .\skills\fix-title\scripts\fix_markdown_titles.py --help
conda run -n skills-vault python -B C:\Users\wangq\.codex\skills\.system\skill-creator\scripts\quick_validate.py skills\bundle-zip
``` ```
Do not rely on bare `python` unless you have first verified it resolves to `C:\Users\wangq\.conda\envs\skills-vault\python.exe`. Do not rely on bare `python` unless you have first verified it resolves to `C:\Users\wangq\.conda\envs\skills-vault\python.exe`.
If pytest cannot access the default Windows temp directory, point `TMP` and `TEMP` at a repository-local ignored directory before running tests:
```powershell
New-Item -ItemType Directory -Force -Path tmp\pytest | Out-Null
$env:TMP = (Resolve-Path -LiteralPath tmp\pytest).Path
$env:TEMP = $env:TMP
conda run -n skills-vault pytest skills\voice-generation\tests -q
```
## Install One Skill ## Install One Skill
PowerShell: PowerShell:
```powershell ```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill bundle-zip
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill fix-title powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill fix-title
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill repair-markdown-citations powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill repair-markdown-citations
``` ```
@ -68,6 +81,7 @@ powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill repa
Git Bash / Linux / macOS: Git Bash / Linux / macOS:
```bash ```bash
bash scripts/install-skill.sh bundle-zip
bash scripts/install-skill.sh fix-title bash scripts/install-skill.sh fix-title
``` ```
@ -101,11 +115,13 @@ By default, install scripts refuse to overwrite an existing Skill.
Use `-Force` or `--force` to back up the current installed directory and copy the repository version: Use `-Force` or `--force` to back up the current installed directory and copy the repository version:
```powershell ```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill bundle-zip -Force
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill fix-title -Force powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill fix-title -Force
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill repair-markdown-citations -Force powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill repair-markdown-citations -Force
``` ```
```bash ```bash
bash scripts/install-skill.sh bundle-zip --force
bash scripts/install-skill.sh fix-title --force bash scripts/install-skill.sh fix-title --force
``` ```

View File

@ -44,7 +44,14 @@ If the source is CCPE content, do not migrate it.
5. Add or repair tests when practical. 5. Add or repair tests when practical.
6. Register the Skill in: 6. Validate tests and Skill metadata through the shared environment:
```powershell
conda run -n skills-vault python -B -m unittest discover -s skills\<skill-name>\tests -v
conda run -n skills-vault python -B C:\Users\wangq\.codex\skills\.system\skill-creator\scripts\quick_validate.py skills\<skill-name>
```
7. Register the Skill in:
```text ```text
registry/skills-index.md registry/skills-index.md
@ -52,13 +59,13 @@ If the source is CCPE content, do not migrate it.
Register only real migrated or maintained Skill source. Do not use the registry as a backlog for speculative candidates. Register only real migrated or maintained Skill source. Do not use the registry as a backlog for speculative candidates.
7. If needed, install or sync the Skill into: 8. If needed, install or sync the Skill into:
```text ```text
C:\Users\wangq\.agents\skills\<skill-name> C:\Users\wangq\.agents\skills\<skill-name>
``` ```
8. Commit the migration. 9. Commit the migration.
## Migration Status Values ## Migration Status Values

View File

@ -4,4 +4,5 @@ channels:
dependencies: dependencies:
- python=3.11 - python=3.11
- pip - pip
- pyyaml
- pytest>=7.0 - pytest>=7.0

View File

@ -13,6 +13,7 @@ Existing CCPE content is not migrated here.
| Skill | Purpose | Source | Status | Install Policy | Installed Path | CCPE Registration | | Skill | Purpose | Source | Status | Install Policy | Installed Path | CCPE Registration |
| --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- |
| `bundle-zip` | Create zip archives from explicit file lists while preserving source-relative paths and validating entries by readback. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\bundle-zip` | `installed` | `default` | `C:\Users\wangq\.agents\skills\bundle-zip` | none |
| `fix-title` | Shift Markdown ATX heading depth for pasted LLM replies before insertion under parent sections. | `C:\Users\wangq\Documents\Codex\knowledge-vault\skills\fix-title` | `installed` | `default` | `C:\Users\wangq\.agents\skills\fix-title` | none | | `fix-title` | Shift Markdown ATX heading depth for pasted LLM replies before insertion under parent sections. | `C:\Users\wangq\Documents\Codex\knowledge-vault\skills\fix-title` | `installed` | `default` | `C:\Users\wangq\.agents\skills\fix-title` | none |
| `repair-markdown-citations` | Repair ChatGPT/Deep Research Markdown citation tokens into standard footnotes and a deduplicated reference section using exact turn-ID metadata mapping. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\repair-markdown-citations` | `installed` | `default` | `C:\Users\wangq\.agents\skills\repair-markdown-citations` | none | | `repair-markdown-citations` | Repair ChatGPT/Deep Research Markdown citation tokens into standard footnotes and a deduplicated reference section using exact turn-ID metadata mapping. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\repair-markdown-citations` | `installed` | `default` | `C:\Users\wangq\.agents\skills\repair-markdown-citations` | none |
| `voice-generation` | Generate spoken audio from Markdown scripts using the local `voice-gen` CLI and MiniMax `mmx`, including custom voice clone registration and voice registry management. | `D:\AI\ClaudeCode\voice-ge` | `installed` | `conditional` | `C:\Users\wangq\.agents\skills\voice-generation` | none | | `voice-generation` | Generate spoken audio from Markdown scripts using the local `voice-gen` CLI and MiniMax `mmx`, including custom voice clone registration and voice registry management. | `D:\AI\ClaudeCode\voice-ge` | `installed` | `conditional` | `C:\Users\wangq\.agents\skills\voice-generation` | none |

View File

@ -19,11 +19,13 @@ skills/<skill-name>/scripts/
Current install scripts: Current install scripts:
```powershell ```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill bundle-zip
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill fix-title powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill fix-title
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -All powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -All
``` ```
```bash ```bash
bash scripts/install-skill.sh bundle-zip
bash scripts/install-skill.sh fix-title bash scripts/install-skill.sh fix-title
bash scripts/install-skill.sh --all bash scripts/install-skill.sh --all
``` ```

View File

@ -0,0 +1,18 @@
# Bundle Zip
Source Skill for creating zip archives from explicit file lists while preserving source-relative paths.
The canonical entry point is `SKILL.md`; the deterministic packager is in `scripts/bundle_zip.py`.
Run Python code through the shared repository environment:
```powershell
conda run -n skills-vault python .\scripts\bundle_zip.py --file-list "C:\path\files.txt" --relative-root-marker repo-name --output "C:\path\bundle.zip" --verify --json
```
Validate from the repository root with the same environment:
```powershell
conda run -n skills-vault python -B -m unittest discover -s skills\bundle-zip\tests -v
conda run -n skills-vault python -B C:\Users\wangq\.codex\skills\.system\skill-creator\scripts\quick_validate.py skills\bundle-zip
```

View File

@ -0,0 +1,56 @@
---
name: bundle-zip
description: Use when Codex needs to create a zip archive from an explicit file list while preserving source-relative paths, especially for GPT/CCRA review bundles, handoff packages, changed-file bundles, or Windows-safe packaging where PowerShell Compress-Archive may flatten paths. Use for newline-delimited file lists, root-marker-based zip entry names, overwrite-safe zip creation, and zip readback validation.
---
# Bundle Zip
## Purpose
Create a zip archive from an explicit newline-delimited file list while preserving paths relative to a named root path segment. Use the bundled Python script instead of PowerShell `Compress-Archive` when exact zip entry names matter.
The script uses Python standard-library `zipfile`; it does not implement compression itself.
## Command
Run from the repository or working directory that should resolve relative file-list entries:
```powershell
conda run -n skills-vault python .\scripts\bundle_zip.py `
--file-list C:\path\files.txt `
--relative-root-marker repo-name `
--output C:\path\bundle.zip `
--verify `
--json
```
If the Skill is installed under `C:\Users\wangq\.agents\skills\bundle-zip`, use that installed script path or run from the installed Skill directory.
## Inputs
- `--file-list`: UTF-8 text file with one source file per line. Blank lines are ignored.
- `--relative-root-marker`: exact path segment that defines where zip entry names start. If a source path contains the marker zero times or more than once, fail.
- `--output`: destination zip path. The parent directory must already exist.
- `--verify`: accepted for explicit workflows; readback verification is always performed.
- `--json`: print machine-readable summary JSON to stdout.
- `--json-output`: optionally write the same validation summary to a JSON file.
File-list entries may be absolute paths or paths relative to the current working directory. Zip entry names always use `/`.
## Safety
- Validate all sources before replacing an existing output zip.
- Reject missing files, directories, duplicate zip entries, and an output path that is also a source file.
- Package only explicitly listed files; do not recursively package directories.
- Write a temporary zip next to the output, verify it, then replace the requested output zip.
- Do not modify source files.
## Validation
After editing the script, run:
```powershell
conda run -n skills-vault python -B -m unittest discover -s skills/bundle-zip/tests -v
```
For full repository confidence, also run relevant neighboring Skill tests when shared code or install behavior changes.

View File

@ -0,0 +1,4 @@
interface:
display_name: "Bundle Zip"
short_description: "Create source-relative zip bundles with validation."
default_prompt: "Use $bundle-zip to create a source-relative zip from this file list and verify the entries."

View File

@ -0,0 +1,3 @@
# Fixtures
This directory is reserved for future bundle-zip fixtures.

View File

@ -0,0 +1,283 @@
from __future__ import annotations
import argparse
import json
import os
import pathlib
import sys
import tempfile
import zipfile
from dataclasses import dataclass
from typing import Iterable
@dataclass(frozen=True)
class SourceEntry:
source: pathlib.Path
entry_name: str
class BundleZipError(Exception):
pass
def normalize_part(value: str) -> str:
if os.name == "nt":
return value.casefold()
return value
def read_file_list(file_list: pathlib.Path) -> list[str]:
if not file_list.exists():
raise BundleZipError(f"File list does not exist: {file_list}")
if not file_list.is_file():
raise BundleZipError(f"File list is not a file: {file_list}")
lines = file_list.read_text(encoding="utf-8").splitlines()
return [line.strip() for line in lines if line.strip()]
def resolve_input_path(value: str, cwd: pathlib.Path) -> pathlib.Path:
path = pathlib.Path(value).expanduser()
if not path.is_absolute():
path = cwd / path
return path.resolve(strict=False)
def entry_name_for(path: pathlib.Path, marker: str) -> str:
normalized_marker = normalize_part(marker)
matches = [
index
for index, part in enumerate(path.parts)
if normalize_part(part) == normalized_marker
]
if not matches:
raise BundleZipError(
f"Source file is not under root marker '{marker}': {path}"
)
if len(matches) > 1:
raise BundleZipError(
f"Source file contains root marker '{marker}' more than once: {path}"
)
relative_parts = path.parts[matches[0] + 1 :]
if not relative_parts:
raise BundleZipError(
f"Source file cannot be made relative to root marker '{marker}': {path}"
)
return "/".join(relative_parts)
def validate_sources(
file_values: Iterable[str],
*,
marker: str,
output: pathlib.Path,
cwd: pathlib.Path,
) -> list[SourceEntry]:
if not marker:
raise BundleZipError("Relative root marker must not be empty")
output = output.resolve(strict=False)
entries: list[SourceEntry] = []
seen: dict[str, pathlib.Path] = {}
for value in file_values:
source = resolve_input_path(value, cwd)
if not source.exists():
raise BundleZipError(f"Source file does not exist: {source}")
if not source.is_file():
raise BundleZipError(f"Source path is not a file: {source}")
if source == output:
raise BundleZipError(f"Output path is the same as a source file: {source}")
entry_name = entry_name_for(source, marker)
if entry_name in seen:
raise BundleZipError(
"Duplicate zip entry produced: "
f"{entry_name} from {seen[entry_name]} and {source}"
)
seen[entry_name] = source
entries.append(SourceEntry(source=source, entry_name=entry_name))
if not entries:
raise BundleZipError("File list did not contain any source files")
return entries
def zip_entries(path: pathlib.Path) -> list[str]:
with zipfile.ZipFile(path) as archive:
return archive.namelist()
def write_zip(entries: list[SourceEntry], output: pathlib.Path) -> list[str]:
output_parent = output.parent.resolve(strict=False)
if not output_parent.exists():
raise BundleZipError(f"Output parent directory does not exist: {output_parent}")
if not output_parent.is_dir():
raise BundleZipError(f"Output parent path is not a directory: {output_parent}")
expected_entries = [entry.entry_name for entry in entries]
temp_name = ""
try:
with tempfile.NamedTemporaryFile(
prefix=f".{output.name}.",
suffix=".tmp",
dir=output_parent,
delete=False,
) as temp_file:
temp_name = temp_file.name
temp_path = pathlib.Path(temp_name)
with zipfile.ZipFile(
temp_path,
"w",
compression=zipfile.ZIP_DEFLATED,
compresslevel=6,
) as archive:
for entry in entries:
archive.write(entry.source, arcname=entry.entry_name)
actual_entries = zip_entries(temp_path)
if actual_entries != expected_entries:
raise BundleZipError(
"Zip verification failed: "
f"expected {expected_entries}, got {actual_entries}"
)
os.replace(temp_path, output)
final_entries = zip_entries(output)
if final_entries != expected_entries:
raise BundleZipError(
"Final zip verification failed: "
f"expected {expected_entries}, got {final_entries}"
)
return final_entries
finally:
if temp_name:
temp_path = pathlib.Path(temp_name)
if temp_path.exists():
temp_path.unlink()
def build_summary(
*,
status: str,
output: pathlib.Path,
source_file_count: int,
entries: list[str],
warnings: list[str] | None = None,
errors: list[str] | None = None,
) -> dict[str, object]:
return {
"status": status,
"output_zip": str(output.resolve(strict=False)),
"source_file_count": source_file_count,
"zip_entry_count": len(entries),
"entries": entries,
"warnings": warnings or [],
"errors": errors or [],
}
def print_summary(summary: dict[str, object], *, as_json: bool) -> None:
if as_json:
print(json.dumps(summary, indent=2, ensure_ascii=False))
return
print(summary["status"])
print(f"output_zip: {summary['output_zip']}")
print(f"source_file_count: {summary['source_file_count']}")
print(f"zip_entry_count: {summary['zip_entry_count']}")
errors = summary.get("errors") or []
warnings = summary.get("warnings") or []
if warnings:
print("warnings:")
for warning in warnings:
print(f"- {warning}")
if errors:
print("errors:")
for error in errors:
print(f"- {error}")
entries = summary.get("entries") or []
if entries:
print("entries:")
for entry in entries:
print(f"- {entry}")
def write_json_output(path: pathlib.Path, summary: dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(summary, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Create a source-relative zip from a newline-delimited file list.",
)
parser.add_argument("--file-list", required=True, help="Newline-delimited file list.")
parser.add_argument(
"--relative-root-marker",
required=True,
help="Path segment used as the root for zip entry names.",
)
parser.add_argument("--output", required=True, help="Destination zip path.")
parser.add_argument(
"--verify",
action="store_true",
help="Accepted for explicit workflows; verification is always performed.",
)
parser.add_argument(
"--json",
action="store_true",
help="Print the validation summary as JSON.",
)
parser.add_argument(
"--json-output",
help="Optional path to write the validation summary JSON.",
)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv or sys.argv[1:])
cwd = pathlib.Path.cwd().resolve(strict=False)
file_list_path = resolve_input_path(args.file_list, cwd)
output = resolve_input_path(args.output, cwd)
file_values: list[str] = []
try:
file_values = read_file_list(file_list_path)
source_entries = validate_sources(
file_values,
marker=args.relative_root_marker,
output=output,
cwd=cwd,
)
entries = write_zip(source_entries, output)
summary = build_summary(
status="PASS",
output=output,
source_file_count=len(source_entries),
entries=entries,
)
exit_code = 0
except BundleZipError as error:
summary = build_summary(
status="FAIL",
output=output,
source_file_count=len(file_values),
entries=[],
errors=[str(error)],
)
exit_code = 1
if args.json_output:
write_json_output(resolve_input_path(args.json_output, cwd), summary)
print_summary(summary, as_json=args.json)
return exit_code
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,208 @@
from __future__ import annotations
import json
import pathlib
import subprocess
import sys
import tempfile
import unittest
import zipfile
SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "scripts" / "bundle_zip.py"
def run_cli(*args: str, cwd: pathlib.Path | None = None) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, str(SCRIPT), *args],
cwd=str(cwd) if cwd else None,
text=True,
capture_output=True,
check=False,
)
def write_file(path: pathlib.Path, text: str = "content") -> pathlib.Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return path
def write_file_list(path: pathlib.Path, files: list[str]) -> pathlib.Path:
path.write_text("\n".join(files) + "\n", encoding="utf-8")
return path
class BundleZipTest(unittest.TestCase):
def test_absolute_files_under_marker_create_source_relative_entries(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = pathlib.Path(tmp) / "the-mindscape-of-bro-tsong"
first = write_file(root / "cards" / "card_index.md", "card")
second = write_file(root / "cards" / "intellectual_archaeology.md", "ia")
file_list = write_file_list(pathlib.Path(tmp) / "files.txt", [str(first), str(second)])
output = pathlib.Path(tmp) / "bundle.zip"
result = run_cli(
"--file-list",
str(file_list),
"--relative-root-marker",
"the-mindscape-of-bro-tsong",
"--output",
str(output),
"--verify",
"--json",
)
self.assertEqual(result.returncode, 0, result.stderr)
summary = json.loads(result.stdout)
self.assertEqual(summary["status"], "PASS")
self.assertEqual(
summary["entries"],
["cards/card_index.md", "cards/intellectual_archaeology.md"],
)
with zipfile.ZipFile(output) as archive:
self.assertEqual(archive.namelist(), summary["entries"])
def test_mixed_absolute_and_cwd_relative_files_create_correct_entries(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = pathlib.Path(tmp) / "repo"
absolute_file = write_file(root / "cards" / "absolute.md")
relative_file = write_file(root / "notes" / "relative.md")
file_list = write_file_list(
root / "files.txt",
[str(absolute_file), r"notes\relative.md"],
)
output = pathlib.Path(tmp) / "mixed.zip"
result = run_cli(
"--file-list",
str(file_list),
"--relative-root-marker",
"repo",
"--output",
str(output),
"--verify",
"--json",
cwd=root,
)
self.assertEqual(result.returncode, 0, result.stderr)
summary = json.loads(result.stdout)
self.assertEqual(summary["entries"], ["cards/absolute.md", "notes/relative.md"])
def test_existing_destination_zip_is_replaced_after_validation(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = pathlib.Path(tmp) / "repo"
source = write_file(root / "src" / "new.md", "new")
file_list = write_file_list(pathlib.Path(tmp) / "files.txt", [str(source)])
output = pathlib.Path(tmp) / "bundle.zip"
with zipfile.ZipFile(output, "w") as archive:
archive.writestr("old.txt", "old")
result = run_cli(
"--file-list",
str(file_list),
"--relative-root-marker",
"repo",
"--output",
str(output),
"--verify",
"--json",
)
self.assertEqual(result.returncode, 0, result.stderr)
with zipfile.ZipFile(output) as archive:
self.assertEqual(archive.namelist(), ["src/new.md"])
self.assertEqual(archive.read("src/new.md"), b"new")
def test_missing_source_fails_before_existing_output_is_touched(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = pathlib.Path(tmp) / "repo"
missing = root / "missing.md"
file_list = write_file_list(pathlib.Path(tmp) / "files.txt", [str(missing)])
output = pathlib.Path(tmp) / "bundle.zip"
with zipfile.ZipFile(output, "w") as archive:
archive.writestr("old.txt", "old")
result = run_cli(
"--file-list",
str(file_list),
"--relative-root-marker",
"repo",
"--output",
str(output),
"--json",
)
self.assertNotEqual(result.returncode, 0)
summary = json.loads(result.stdout)
self.assertEqual(summary["status"], "FAIL")
self.assertIn("does not exist", summary["errors"][0])
with zipfile.ZipFile(output) as archive:
self.assertEqual(archive.namelist(), ["old.txt"])
def test_file_outside_root_marker_fails(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
outside = write_file(pathlib.Path(tmp) / "outside" / "file.md")
file_list = write_file_list(pathlib.Path(tmp) / "files.txt", [str(outside)])
result = run_cli(
"--file-list",
str(file_list),
"--relative-root-marker",
"repo",
"--output",
str(pathlib.Path(tmp) / "bundle.zip"),
"--json",
)
self.assertNotEqual(result.returncode, 0)
summary = json.loads(result.stdout)
self.assertEqual(summary["status"], "FAIL")
self.assertIn("root marker", summary["errors"][0])
def test_duplicate_relative_entries_fail(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
first = write_file(pathlib.Path(tmp) / "left" / "repo" / "same.md", "first")
second = write_file(pathlib.Path(tmp) / "right" / "repo" / "same.md", "second")
file_list = write_file_list(pathlib.Path(tmp) / "files.txt", [str(first), str(second)])
result = run_cli(
"--file-list",
str(file_list),
"--relative-root-marker",
"repo",
"--output",
str(pathlib.Path(tmp) / "bundle.zip"),
"--json",
)
self.assertNotEqual(result.returncode, 0)
summary = json.loads(result.stdout)
self.assertEqual(summary["status"], "FAIL")
self.assertIn("Duplicate zip entry", "\n".join(summary["errors"]))
def test_output_path_matching_source_file_fails(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = pathlib.Path(tmp) / "repo"
source = write_file(root / "bundle.zip")
file_list = write_file_list(pathlib.Path(tmp) / "files.txt", [str(source)])
result = run_cli(
"--file-list",
str(file_list),
"--relative-root-marker",
"repo",
"--output",
str(source),
"--json",
)
self.assertNotEqual(result.returncode, 0)
summary = json.loads(result.stdout)
self.assertEqual(summary["status"], "FAIL")
self.assertIn("same as a source file", "\n".join(summary["errors"]))
if __name__ == "__main__":
unittest.main()