From 287ef86eca318a849c1d83cc7220459028171822 Mon Sep 17 00:00:00 2001 From: wantsong Date: Thu, 18 Jun 2026 01:44:16 +0800 Subject: [PATCH] Add bundle-zip skill --- docs/install-sync.md | 2 + docs/installation.md | 20 +- docs/migration-guide.md | 13 +- environment.yml | 1 + registry/skills-index.md | 1 + scripts/README.md | 2 + skills/bundle-zip/README.md | 18 ++ skills/bundle-zip/SKILL.md | 56 ++++ skills/bundle-zip/agents/openai.yaml | 4 + skills/bundle-zip/fixtures/README.md | 3 + skills/bundle-zip/scripts/bundle_zip.py | 283 +++++++++++++++++++++ skills/bundle-zip/tests/test_bundle_zip.py | 208 +++++++++++++++ 12 files changed, 606 insertions(+), 5 deletions(-) create mode 100644 skills/bundle-zip/README.md create mode 100644 skills/bundle-zip/SKILL.md create mode 100644 skills/bundle-zip/agents/openai.yaml create mode 100644 skills/bundle-zip/fixtures/README.md create mode 100644 skills/bundle-zip/scripts/bundle_zip.py create mode 100644 skills/bundle-zip/tests/test_bundle_zip.py diff --git a/docs/install-sync.md b/docs/install-sync.md index 51f9cd3..03a628d 100644 --- a/docs/install-sync.md +++ b/docs/install-sync.md @@ -21,12 +21,14 @@ This repository includes install scripts for copying reviewed Skill source into 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 ``` Use `-Force` to back up and replace an existing installed copy: ```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 ``` diff --git a/docs/installation.md b/docs/installation.md index a0d3624..ab66ef4 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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. +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: ```powershell -conda run -n skills-vault python -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 -m unittest discover -s skills\fix-title\tests -v +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`. +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 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 repair-markdown-citations ``` @@ -68,6 +81,7 @@ powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill repa Git Bash / Linux / macOS: ```bash +bash scripts/install-skill.sh bundle-zip 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: ```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 repair-markdown-citations -Force ``` ```bash +bash scripts/install-skill.sh bundle-zip --force bash scripts/install-skill.sh fix-title --force ``` diff --git a/docs/migration-guide.md b/docs/migration-guide.md index 9b24ec0..8a4a181 100644 --- a/docs/migration-guide.md +++ b/docs/migration-guide.md @@ -44,7 +44,14 @@ If the source is CCPE content, do not migrate it. 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\\tests -v + conda run -n skills-vault python -B C:\Users\wangq\.codex\skills\.system\skill-creator\scripts\quick_validate.py skills\ + ``` + +7. Register the Skill in: ```text 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. -7. If needed, install or sync the Skill into: +8. If needed, install or sync the Skill into: ```text C:\Users\wangq\.agents\skills\ ``` -8. Commit the migration. +9. Commit the migration. ## Migration Status Values diff --git a/environment.yml b/environment.yml index 5cfe929..11b1d09 100644 --- a/environment.yml +++ b/environment.yml @@ -4,4 +4,5 @@ channels: dependencies: - python=3.11 - pip + - pyyaml - pytest>=7.0 diff --git a/registry/skills-index.md b/registry/skills-index.md index 1fe6d29..be4c633 100644 --- a/registry/skills-index.md +++ b/registry/skills-index.md @@ -13,6 +13,7 @@ Existing CCPE content is not migrated here. | 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 | | `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 | diff --git a/scripts/README.md b/scripts/README.md index 4cf7892..62b91bb 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -19,11 +19,13 @@ skills//scripts/ Current install scripts: ```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 -All ``` ```bash +bash scripts/install-skill.sh bundle-zip bash scripts/install-skill.sh fix-title bash scripts/install-skill.sh --all ``` diff --git a/skills/bundle-zip/README.md b/skills/bundle-zip/README.md new file mode 100644 index 0000000..7058fc7 --- /dev/null +++ b/skills/bundle-zip/README.md @@ -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 +``` diff --git a/skills/bundle-zip/SKILL.md b/skills/bundle-zip/SKILL.md new file mode 100644 index 0000000..82e820b --- /dev/null +++ b/skills/bundle-zip/SKILL.md @@ -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. diff --git a/skills/bundle-zip/agents/openai.yaml b/skills/bundle-zip/agents/openai.yaml new file mode 100644 index 0000000..cd053d1 --- /dev/null +++ b/skills/bundle-zip/agents/openai.yaml @@ -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." diff --git a/skills/bundle-zip/fixtures/README.md b/skills/bundle-zip/fixtures/README.md new file mode 100644 index 0000000..a307f06 --- /dev/null +++ b/skills/bundle-zip/fixtures/README.md @@ -0,0 +1,3 @@ +# Fixtures + +This directory is reserved for future bundle-zip fixtures. diff --git a/skills/bundle-zip/scripts/bundle_zip.py b/skills/bundle-zip/scripts/bundle_zip.py new file mode 100644 index 0000000..19ef3e4 --- /dev/null +++ b/skills/bundle-zip/scripts/bundle_zip.py @@ -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()) diff --git a/skills/bundle-zip/tests/test_bundle_zip.py b/skills/bundle-zip/tests/test_bundle_zip.py new file mode 100644 index 0000000..78f9f81 --- /dev/null +++ b/skills/bundle-zip/tests/test_bundle_zip.py @@ -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()