← Files Agentic Course RedesignARCHIVED FILE

scripts/setup_course_project.py

7.24 KB · Sep 30, 2026 · 23:14 UTC

↓ Download file

#!/usr/bin/env python3
"""Preview or install the course-redesign project template without overwrites."""

from __future__ import annotations

import argparse
import hashlib
import importlib.util
import json
import os
import shutil
from datetime import datetime, timezone
from pathlib import Path

SCRIPT_PATH = Path(__file__).resolve()


def load_eligibility_validator():
    sibling = SCRIPT_PATH.with_name("source_manifest.py")
    spec = importlib.util.spec_from_file_location("_acr_source_manifest", sibling)
    if spec is None or spec.loader is None:
        raise ImportError(f"cannot load eligibility validator from {sibling}")
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)
    return module.validate_eligibility


validate_eligibility = load_eligibility_validator()


def locate_template_root() -> Path:
    candidates: list[Path] = []
    for ancestor in SCRIPT_PATH.parents:
        candidates.extend(
            [
                ancestor / "course-project-template",
                ancestor / "assets" / "project-template",
                ancestor,
            ]
        )
    for candidate in candidates:
        if (candidate / "AGENTS.md").is_file() and (
            candidate / "01_Control" / "state.json"
        ).is_file():
            return candidate
    raise FileNotFoundError(
        "course project template not found in shared-core, plugin-assets or workspace-overlay layout"
    )


TEMPLATE_ROOT = locate_template_root()
CORE_ROOT = TEMPLATE_ROOT.parent


def sha256(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest().upper()


def target_is_dangerously_broad(path: Path) -> bool:
    resolved = path.resolve(strict=False)
    home = Path.home().resolve(strict=False)
    anchor = Path(resolved.anchor).resolve(strict=False)
    return resolved == anchor or resolved == home or len(resolved.parts) < 4


def template_files() -> list[Path]:
    return sorted(p for p in TEMPLATE_ROOT.rglob("*") if p.is_file())


def build_report(
    target: Path, eligibility_record: Path | None = None
) -> dict[str, object]:
    target = target.expanduser().resolve(strict=False)
    eligibility = {"ok": False, "errors": ["Gate-0A eligibility record not supplied"]}
    if eligibility_record is not None:
        eligibility = validate_eligibility(eligibility_record.expanduser().resolve(strict=False))
    files = template_files()
    planned = []
    conflicts = []
    for source in files:
        relative = source.relative_to(TEMPLATE_ROOT)
        destination = target / relative
        record = {
            "relative_path": relative.as_posix(),
            "destination": str(destination),
            "bytes": source.stat().st_size,
            "sha256": sha256(source),
        }
        planned.append(record)
        if destination.exists() or destination.is_symlink():
            conflicts.append(record)
    existing_entries: list[str] | None = []
    target_contents_inspected = False
    if target.exists() and eligibility["ok"]:
        existing_entries = [str(p.relative_to(target)) for p in target.iterdir()]
        target_contents_inspected = True
    elif target.exists():
        # Do not enumerate arbitrary names in an existing folder before Gate 0A.
        existing_entries = None
    return {
        "mode": "preview",
        "core_root": str(CORE_ROOT),
        "template_root": str(TEMPLATE_ROOT),
        "target": str(target),
        "target_exists": target.exists(),
        "target_contents_inspected": target_contents_inspected,
        "target_existing_top_level_entries": sorted(existing_entries)
        if existing_entries is not None
        else None,
        "gate_0a_eligibility_valid": bool(eligibility["ok"]),
        "gate_0a_eligibility_errors": eligibility["errors"],
        "existing_target_requires_gate_0a_before_content_listing_or_apply": True,
        "planned_file_count": len(planned),
        "planned_files": planned,
        "conflict_count": len(conflicts),
        "conflicts": conflicts,
        "would_overwrite": bool(conflicts),
    }


def install(
    target: Path,
    allow_nonempty: bool,
    eligibility_record: Path | None = None,
) -> dict[str, object]:
    report = build_report(target, eligibility_record)
    target = Path(str(report["target"]))
    if target_is_dangerously_broad(target):
        raise ValueError(f"Refusing dangerously broad target: {target}")
    if report["conflict_count"]:
        raise FileExistsError("Refusing to overwrite existing template paths")
    if report["target_exists"] and not report["gate_0a_eligibility_valid"]:
        raise PermissionError(
            "Refusing to inspect or add to an existing target before approved Gate 0A; "
            "use a new absent target or provide --eligibility-record"
        )
    if report["target_existing_top_level_entries"] and not allow_nonempty:
        raise FileExistsError(
            "Target is not empty. Rerun only after lecturer approval with --allow-nonempty."
        )
    target.mkdir(parents=True, exist_ok=True)
    installed = []
    for source in template_files():
        relative = source.relative_to(TEMPLATE_ROOT)
        destination = target / relative
        destination.parent.mkdir(parents=True, exist_ok=True)
        if destination.exists() or destination.is_symlink():
            raise FileExistsError(f"Race-safe overwrite refusal: {destination}")
        shutil.copy2(source, destination)
        if sha256(source) != sha256(destination):
            raise OSError(f"Copy verification failed: {destination}")
        installed.append(relative.as_posix())
    report.update(
        {
            "mode": "apply",
            "installed": True,
            "installed_at_utc": datetime.now(timezone.utc).isoformat(),
            "installed_files": installed,
            "post_install_conflict_count": 0,
        }
    )
    return report


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--target", required=True, type=Path)
    parser.add_argument("--apply", action="store_true")
    parser.add_argument("--allow-nonempty", action="store_true")
    parser.add_argument(
        "--eligibility-record",
        type=Path,
        help="approved fingerprinted Gate-0A record required before inspecting or adding to an existing target",
    )
    args = parser.parse_args()
    try:
        report = (
            install(args.target, args.allow_nonempty, args.eligibility_record)
            if args.apply
            else build_report(args.target, args.eligibility_record)
        )
        print(json.dumps(report, indent=2, ensure_ascii=False))
        return 0 if not report.get("would_overwrite") else 2
    except Exception as exc:  # fail closed with a machine-readable result
        print(
            json.dumps(
                {
                    "mode": "apply" if args.apply else "preview",
                    "installed": False,
                    "target": os.fspath(args.target),
                    "error_type": type(exc).__name__,
                    "error": str(exc),
                },
                indent=2,
            )
        )
        return 1


if __name__ == "__main__":
    raise SystemExit(main())

SHA-256: eb2979144efb5d62edbfba0bbe9bf743639ef6aa8cca04943153f465a9e37046