#!/usr/bin/env python3
"""Local skill packaging helper, NOT a semantic router or official host validator.

Python >=3.10. PyYAML and markdown-it-py for validate/build/plugin checks.
Pillow for declared raster icons. Missing dependencies return BLOCKED. No network, target-code
execution, account writes or implicit installation. inspect/validate are read-only;
extract/build require new destinations. Conservative local limits: 1000 files,
16 MiB/member, 64 MiB expanded total, 20 path components. These are NOT host limits.
Unknown/unchecked fields produce REVIEW (exit 3) and block build.
Directory icon-dimension findings are separate: packaging_status PASS permits
a byte-preserving artifact; overall status remains REVIEW (exit 3). Check
artifact_created and target_compatibility. This is not Directory approval.
Limits and naming checks are conservative local policies, not universal host rules.
Read-only operations never execute target code. No concurrent-hostile-writer guarantee.
"""
from __future__ import annotations
import argparse
import hashlib
import io
import json
import os
import posixpath
import math
import xml.etree.ElementTree as ET
from pathlib import Path, PurePosixPath
import re
import shutil
import stat
import sys
import unicodedata
from urllib.parse import unquote, urlsplit
import zipfile

MAX_FILES = 1000
MAX_FILE = 16 * 1024 * 1024
MAX_TOTAL = 64 * 1024 * 1024
IGNORED = {".git", "__pycache__", ".pytest_cache", ".DS_Store"}

class Invalid(ValueError):
    pass

class Blocked(RuntimeError):
    pass

def sha(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()

def safe_path(name: str) -> PurePosixPath:
    if (not name or name != name.strip() or "\\" in name or ":" in name
            or any(ord(c) < 32 or ord(c) == 127 for c in name)):
        raise Invalid(f"unsafe_path:{name!r}")
    parts = name.split("/")
    if len(parts) > 20 or any(p in {"", ".", ".."} for p in parts):
        raise Invalid(f"unsafe_path:{name!r}")
    if any(p.endswith((" ", ".")) for p in parts):
        raise Invalid(f"nonportable_path:{name!r}")
    path = PurePosixPath(name)
    if path.is_absolute():
        raise Invalid(f"absolute_path:{name!r}")
    return path

def check_names(names: list[str]) -> None:
    normalized: dict[str, str] = {}
    for name in names:
        safe_path(name)
        key = unicodedata.normalize("NFC", name).casefold()
        if key in normalized:
            raise Invalid(f"path_collision:{normalized[key]}:{name}")
        normalized[key] = name
    components = {}
    for name in names:
        parts = name.split("/")
        for i in range(1, len(parts) + 1):
            prefix = "/".join(parts[:i])
            key = unicodedata.normalize("NFC", prefix).casefold()
            if key in components and components[key] != prefix:
                raise Invalid(f"path_component_collision:{components[key]}:{prefix}")
            components[key] = prefix
    # Callers pass regular-file names. A regular file cannot be another's parent.
    for key in normalized:
        p = PurePosixPath(key)
        for ancestor in p.parents:
            if str(ancestor) in normalized:
                raise Invalid(f"file_directory_conflict:{key}")

def archive_bytes(path: Path) -> dict[str, bytes]:
    if path.is_symlink() or not path.is_file():
        raise Invalid("archive_not_regular_file")
    if path.stat().st_size > MAX_TOTAL:
        raise Invalid("local_archive_size_limit")
    with zipfile.ZipFile(path) as z:
        return checked_archive(z)

def checked_archive(z: zipfile.ZipFile) -> dict[str, bytes]:
    """Inspect final entry names, kinds, limits and CRC; caller owns the open ZIP."""
    infos = z.infolist()
    if not infos or len(infos) > MAX_FILES:
        raise Invalid("empty_or_too_many_entries")
    if sum(i.file_size for i in infos) > MAX_TOTAL:
        raise Invalid("local_total_size_limit")
    all_names: dict[str, str] = {}
    files: list[zipfile.ZipInfo] = []
    directories: list[str] = []
    for i in infos:
        name = i.filename[:-1] if i.is_dir() else i.filename
        safe_path(name)
        key = unicodedata.normalize("NFC", name).casefold()
        if key in all_names:
            raise Invalid(f"path_collision:{name}")
        all_names[key] = name
        kind = stat.S_IFMT(i.external_attr >> 16)
        allowed = {0, stat.S_IFDIR} if i.is_dir() else {0, stat.S_IFREG}
        if kind not in allowed:
            raise Invalid(f"unsupported_member_type:{name}")
        if i.flag_bits & 1 or i.compress_type not in {zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED}:
            raise Invalid(f"unsupported_or_encrypted_member:{name}")
        if i.file_size > MAX_FILE:
            raise Invalid(f"local_member_size_limit:{name}")
        if i.is_dir():
            if i.file_size:
                raise Invalid(f"directory_contains_data:{name}")
            directories.append(key)
        else:
            files.append(i)
    check_names([i.filename for i in files])
    file_keys = {unicodedata.normalize("NFC", i.filename).casefold() for i in files}
    for directory in directories:
        if any(str(p) in file_keys for p in [PurePosixPath(directory), *PurePosixPath(directory).parents]):
            raise Invalid(f"file_directory_conflict:{directory}")
    # Reading every entry also verifies its CRC. No writes occur in this function.
    return {i.filename: z.read(i) for i in files}


def canonical_root(path: Path) -> Path:
    """Allow symlinked ancestors; reject an explicitly symlinked package root."""
    if path.is_symlink():
        raise Invalid(f"symlink_root:{path}")
    root = path.resolve(strict=True)
    if not root.is_dir():
        raise Invalid("package_root_missing")
    return root


def new_destination(path: Path, source: Path | None = None) -> Path:
    # Resolve the parent only. Checking exists() alone misses dangling links.
    if path.is_symlink() or os.path.lexists(path):
        raise Invalid("output_exists_or_symlink:no_overwrite")
    parent = path.parent.resolve(strict=True)
    if not parent.is_dir():
        raise Invalid("output_parent_not_directory")
    safe_path(path.name)
    dst = parent / path.name
    if os.path.lexists(dst):
        raise Invalid("output_exists_or_symlink:no_overwrite")
    if source is not None and dst.is_relative_to(source.resolve(strict=True)):
        raise Invalid("output_inside_source")
    return dst


def write_new(path: Path, content: bytes) -> None:
    flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
    fd = os.open(path, flags, 0o644)
    with os.fdopen(fd, "wb") as f:
        f.write(content)


def snapshot(root: Path) -> dict[str, bytes]:
    root = canonical_root(root)
    paths = []
    for current, dirs, files in os.walk(root, followlinks=False):
        for name in dirs + files:
            p = Path(current) / name
            if p.is_symlink():
                raise Invalid(f"symlink:{p.relative_to(root)}")
        dirs[:] = [d for d in dirs if d not in IGNORED]
        for name in files:
            if name in IGNORED or name.endswith((".pyc", ".pyo")):
                continue
            p = Path(current) / name
            if not stat.S_ISREG(p.lstat().st_mode):
                raise Invalid(f"not_regular_file:{p}")
            paths.append(p)
    if not paths or len(paths) > MAX_FILES:
        raise Invalid("empty_or_too_many_files")
    check_names([p.relative_to(root).as_posix() for p in paths])
    if sum(p.stat().st_size for p in paths) > MAX_TOTAL:
        raise Invalid("local_total_size_limit")
    data = {}
    for p in sorted(paths):
        if p.stat().st_size > MAX_FILE:
            raise Invalid(f"local_member_size_limit:{p}")
        # No-follow final open; also retain the snapshot-before/after build check.
        fd = os.open(p, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
        with os.fdopen(fd, "rb") as f:
            content = f.read(MAX_FILE + 1)
        if len(content) > MAX_FILE:
            raise Invalid(f"local_member_size_limit:{p}")
        data[p.relative_to(root).as_posix()] = content
    if sum(map(len, data.values())) > MAX_TOTAL:
        raise Invalid("local_total_size_limit")
    return data


def yaml_mapping(text: str, label: str) -> dict:
    try:
        import yaml
    except ImportError as exc:
        raise Blocked("PyYAML required; not installed automatically") from exc
    class UniqueLoader(yaml.SafeLoader):
        pass
    def mapping(loader, node, deep=False):
        result = {}
        for key_node, value_node in node.value:
            key = loader.construct_object(key_node, deep=deep)
            if not isinstance(key, str):
                raise Invalid(f"non_string_yaml_key:{label}")
            if key in result:
                raise Invalid(f"duplicate_yaml_key:{label}:{key}")
            result[key] = loader.construct_object(value_node, deep=deep)
        return result
    UniqueLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, mapping)
    value = yaml.load(text, Loader=UniqueLoader)
    if not isinstance(value, dict):
        raise Invalid(f"not_yaml_mapping:{label}")
    return value


def json_mapping(content: bytes, label: str) -> dict:
    def unique(pairs):
        result = {}
        for k, v in pairs:
            if k in result:
                raise Invalid(f"duplicate_json_key:{label}:{k}")
            result[k] = v
        return result
    value = json.loads(content.decode("utf-8"), object_pairs_hook=unique,
                       parse_constant=lambda x: (_ for _ in ()).throw(Invalid(f"nonfinite_json:{x}")))
    if not isinstance(value, dict):
        raise Invalid(f"not_json_object:{label}")
    return value


def report(data: dict[str, bytes], errors: list[str], warnings: list[str], scope: str,
           image_checks: list[dict] | None = None) -> dict:
    # Local unknowns/security reviews block build. ONLY separately classified
    # Directory dimensions may coexist with a complete local artifact.
    checks = image_checks or []
    issues = [f"{c['path']}:{issue}" for c in checks for issue in c['directory_issues']]
    local = "FAIL" if errors else "REVIEW" if warnings else "PASS"
    return {"status": "FAIL" if errors else "REVIEW" if warnings or issues else "PASS",
            "packaging_status": local, "build_eligible": local == "PASS" and scope in {"LOCAL_SKILL_AND_DECLARED_REFERENCES", "LOCAL_SKILLS_ONLY_PLUGIN_SUBSET"},
            "artifact_created": False,
            "scope": scope, "errors": sorted(set(errors)), "warnings": sorted(set(warnings)),
            "review_required": bool(warnings or issues),
            "target_compatibility": {
                "profile": "OPENAI_DIRECTORY_DECLARED_ICON_DIMENSIONS",
                "status": "FAIL" if issues else "PASS" if checks else "NOT_RUN",
                "issues": sorted(set(issues)), "inspections": checks,
                "scope": "Only the declared icons against the dated Directory image profile; not full submission or private import acceptance.",
                "documented_on": "2026-09-24", "actual_host_acceptance": "NOT_RUN"},
            "files": {p: {"bytes": len(b), "sha256": sha(b)} for p, b in sorted(data.items())},
            "official_import": "NOT_RUN", "model_behavior": "NOT_RUN",
            "native_routing": "NOT_RUN", "installation": "NOT_RUN"}


def frontmatter(content: bytes) -> tuple[dict, str]:
    text = content.decode("utf-8").replace("\r\n", "\n")
    m = re.match(r"\A---\n(.*?)\n---(?:\n|$)", text, re.S)
    if not m:
        raise Invalid("frontmatter_missing_or_malformed")
    return yaml_mapping(m.group(1), "SKILL.md"), text[m.end():]


def local_reference(rel: str, target: str, data: dict[str, bytes], errors: list[str]) -> None:
    parsed = urlsplit(target)
    if parsed.scheme or parsed.netloc or not parsed.path:
        return
    path = unquote(parsed.path)
    if path.startswith("/") or "\\" in path or ":" in path:
        errors.append(f"reference_outside_root:{rel}:{target}"); return
    resolved = posixpath.normpath(posixpath.join(posixpath.dirname(rel), path))
    if resolved == ".." or resolved.startswith("../"):
        errors.append(f"reference_outside_root:{rel}:{target}")
    elif resolved not in data:
        errors.append(f"missing_local_file:{rel}:{target}")


def markdown_links(data: dict[str, bytes], errors: list[str], warnings: list[str]) -> None:
    try:
        from markdown_it import MarkdownIt
    except ImportError as exc:
        raise Blocked("markdown-it-py required for CommonMark link checks") from exc
    parser = MarkdownIt("commonmark")
    def visit(tokens, rel):
        for token in tokens:
            if token.type in {"link_open", "image"}:
                local_reference(rel, token.attrGet("href") or token.attrGet("src") or "", data, errors)
            if token.type in {"html_inline", "html_block"}:
                warnings.append(f"html_not_link_checked:{rel}")
            if token.children:
                visit(token.children, rel)
    for rel, content in data.items():
        if rel.endswith(".md"):
            text = content.decode("utf-8")
            if rel == "SKILL.md":
                _, text = frontmatter(content)
            # CommonMark tokens handle inline/fenced/indented code and reference links.
            # Undefined reference labels are plain text per CommonMark; see scope notes.
            visit(parser.parse(text), rel)


SVG_NUMBER = r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?"
SVG_LENGTH_UNITS = {"", "px", "%", "em", "ex", "rem", "ch", "vw", "vh", "vmin", "vmax", "cm", "mm", "in", "pt", "pc", "q"}


def svg_length(value: str | None) -> dict:
    """Classify without changing bytes or inventing a viewport for relative units."""
    if value is None or value.strip() == "auto":
        return {"raw": value, "kind": "unspecified", "px": None}
    m = re.fullmatch(r"\s*(" + SVG_NUMBER + r")([A-Za-z%]*)\s*", value)
    if not m or m.group(2).lower() not in SVG_LENGTH_UNITS:
        raise Invalid("svg_length_invalid")
    number, unit = float(m.group(1)), m.group(2).lower()
    if not math.isfinite(number) or number <= 0:
        raise Invalid("svg_dimensions_not_positive_finite")
    return {"raw": value, "kind": "number" if not unit else unit,
            "px": number if unit in {"", "px"} else None}


def image_check(rel: str, data: dict[str, bytes], errors: list[str], warnings: list[str],
                image_checks: list[dict]) -> None:
    raw = data.get(rel)
    if raw is None:
        errors.append(f"missing_icon:{rel}"); return
    if len(raw) > 5 * 1024 * 1024:
        # Retained conservative local read/decode cap, independent of icon geometry.
        errors.append(f"image_size_limit:{rel}"); return
    record = {"path": rel, "directory_issues": [], "width_px": None, "height_px": None}
    issues = record["directory_issues"]
    try:
        if rel.lower().endswith(".svg"):
            if b"<!DOCTYPE" in raw.upper() or b"<!ENTITY" in raw.upper():
                raise Invalid("svg_entities_unsupported")
            el = ET.fromstring(raw.decode("utf-8"))
            if el.tag.split("}")[-1] != "svg":
                raise Invalid("svg_root_invalid")
            width = svg_length(el.get("width")); height = svg_length(el.get("height"))
            record.update(format="SVG", width=width, height=height, viewBox_raw=el.get("viewBox"))
            vb = None
            if el.get("viewBox") is not None:
                parts = re.split(r"[\s,]+", el.get("viewBox").strip())
                if len(parts) != 4 or any(not re.fullmatch(SVG_NUMBER, n) for n in parts):
                    raise Invalid("svg_viewbox_invalid")
                vb = [float(n) for n in parts]
                if any(not math.isfinite(n) for n in vb) or min(vb[2:]) <= 0:
                    raise Invalid("svg_viewbox_not_finite_positive")
            # Only a genuinely viewBox-only SVG uses its dimensions. Explicit %,
            # units, auto, or one missing dimension are never hidden by viewBox.
            if el.get("width") is None and el.get("height") is None and vb is not None:
                w, h = vb[2:]; record["measurement"] = "numeric_viewBox_only"
            else:
                w, h = width["px"], height["px"]
                record["measurement"] = "declared_dimensions"
                if any(x["kind"] not in {"number", "unspecified"} for x in [width, height]):
                    issues.append("svg_dimensions_must_omit_units_and_percentages")
                if w is None or h is None:
                    issues.append("svg_absolute_dimensions_unresolved")
            record.update(width_px=w, height_px=h)
            # These are local unreviewed-content findings, not buildable branding
            # notices. This is bounded inspection, NOT full SVG sanitization.
            for node in el.iter():
                tag = node.tag.split("}")[-1].lower()
                if tag in {"script", "foreignobject"}:
                    warnings.append(f"svg_active_content_requires_review:{rel}")
                for k, v in node.attrib.items():
                    key = k.split("}")[-1].lower()
                    if key.startswith("on"):
                        warnings.append(f"svg_event_handler_requires_review:{rel}")
                    if key == "href" and not v.startswith("#"):
                        warnings.append(f"svg_reference_requires_review:{rel}")
                    if key == "style" and re.search(r"url\s*\(|@import|expression\s*\(", v, re.I):
                        warnings.append(f"svg_style_reference_requires_review:{rel}")
                if tag == "style" and node.text and re.search(r"url\s*\(|@import|expression\s*\(", node.text, re.I):
                    warnings.append(f"svg_style_reference_requires_review:{rel}")
        else:
            try:
                from PIL import Image
            except ImportError as exc:
                raise Blocked("Pillow required for declared raster icons") from exc
            with Image.open(io.BytesIO(raw)) as img:
                w, h = img.size
                ext = Path(rel).suffix.lower()
                if {".png":"PNG", ".jpg":"JPEG", ".jpeg":"JPEG", ".webp":"WEBP"}.get(ext) != img.format:
                    raise Invalid("image_extension_content_mismatch")
                if w*h > 4096*4096:
                    raise Invalid("image_dimensions_too_large")
                record.update(format=img.format, width_px=w, height_px=h, measurement="decoded_raster")
                img.verify()
            if w > 4096 or h > 4096:
                issues.append("directory_raster_axis_exceeds_4096")
        if w is not None and h is not None and (w != h or min(w, h) < 48):
            issues.append("directory_icon_must_be_square_min48")
        image_checks.append(record)
    except Blocked:
        raise
    except Exception as exc:
        errors.append(f"invalid_image:{rel}:{type(exc).__name__}:{exc}")


def check_skill(data: dict[str, bytes], folder_name: str | None = None) -> dict:
    errors, warnings = [], []
    image_checks = []
    if "SKILL.md" not in data:
        raise Invalid("SKILL.md_missing")
    fm, body = frontmatter(data["SKILL.md"])
    name, desc = fm.get("name"), fm.get("description")
    if not isinstance(name, str) or not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name) or len(name) > 64:
        errors.append("local_safe_skill_name_required")
    if folder_name and folder_name != name:
        warnings.append("local_folder_name_differs_from_identity")
    if not isinstance(desc, str) or not desc.strip() or len(desc) > 1024:
        errors.append("description_missing_or_length")
    if not body.strip():
        errors.append("skill_body_empty")
    for key in fm.keys() - {"name", "description"}:
        warnings.append(f"frontmatter_field_not_checked:{key}")
    markdown_links(data, errors, warnings)
    if "agents/openai.yaml" in data:
        meta = yaml_mapping(data["agents/openai.yaml"].decode("utf-8"), "agents/openai.yaml")
        for key in meta.keys() - {"interface", "policy", "dependencies"}:
            warnings.append(f"agent_field_not_checked:{key}")
        policy = meta.get("policy", {})
        if not isinstance(policy, dict):
            errors.append("policy_not_mapping")
        else:
            if set(policy) - {"products", "allow_implicit_invocation"}:
                errors.append("unsupported_policy_field")
            if "allow_implicit_invocation" in policy and type(policy["allow_implicit_invocation"]) is not bool:
                errors.append("implicit_invocation_not_boolean")
            if "products" in policy:
                products = policy["products"]
                if not isinstance(products, list) or not products or any(not isinstance(x, str) or x not in {"CHAT", "CODEX"} for x in products):
                    errors.append("unsupported_products")
        interface = meta.get("interface", {})
        if not isinstance(interface, dict):
            errors.append("interface_not_mapping")
        else:
            if any(not isinstance(interface.get(k), str) or not interface[k].strip() for k in ("display_name", "short_description")):
                errors.append("interface_required_fields_missing")
            known = {"display_name", "short_description", "default_prompt", "icon_small", "icon_large", "brand_color"}
            for key, value in interface.items():
                if key not in known:
                    warnings.append(f"interface_field_not_checked:{key}"); continue
                if not isinstance(value, str) or not value.strip():
                    errors.append(f"interface_string_required:{key}")
                elif key.startswith("icon_"):
                    rel = value.removeprefix("./")
                    safe_path(rel)
                    image_check(rel, data, errors, warnings, image_checks)
                elif key == "brand_color" and not re.fullmatch(r"#[0-9a-fA-F]{6}", value):
                    errors.append("brand_color_format")
        if "dependencies" in meta:
            warnings.append("dependencies_present:requires_host_and_contract_review")
    result = report(data, errors, warnings, "LOCAL_SKILL_AND_DECLARED_REFERENCES", image_checks)
    result["identity"] = name
    return result


def validate(root: Path) -> tuple[dict, dict[str, bytes]]:
    data = snapshot(root)
    return check_skill(data, canonical_root(root).name), data


PORTABLE_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"


def plugin_data(path: Path) -> tuple[dict[str, bytes], str]:
    if path.is_dir():
        return snapshot(path), "directory"
    data = archive_bytes(path)
    markers = {"plugin.json", ".codex-plugin/plugin.json"}
    if not (set(data) & markers):
        tops = {p.split("/")[0] for p in data}
        if len(tops) != 1 or any("/" not in p for p in data):
            raise Invalid("plugin_root_ambiguous_or_missing")
        prefix = next(iter(tops)) + "/"
        stripped = {p[len(prefix):]: b for p,b in data.items()}
        if not (set(stripped) & markers):
            raise Invalid("plugin_manifest_missing")
        return stripped, prefix
    return data, "archive_root"


def validate_plugin(path: Path, expected: list[str] | None = None,
                    sources: dict[str, Path] | None = None, changes: dict | None = None) -> tuple[dict, dict[str, bytes]]:
    data, packaging_root = plugin_data(path)
    errors, warnings = [], []
    image_checks = []
    root = json_mapping(data["plugin.json"], "plugin.json") if "plugin.json" in data else None
    overlay = json_mapping(data[".codex-plugin/plugin.json"], ".codex-plugin/plugin.json") if ".codex-plugin/plugin.json" in data else None
    if root is None and overlay is None:
        raise Invalid("plugin_manifest_missing")
    portable = root is not None
    if portable:
        if root.get("$schema") != PORTABLE_SCHEMA:
            raise Blocked("unrecognized_portable_schema:review_current_contract")
        manifest = root
        extra = root.get("extensions", {})
        if not isinstance(extra, dict):
            raise Invalid("extensions_not_object")
        for k in extra.keys() - {"com.openai"}:
            warnings.append(f"extension_not_checked:{k}")
        inline = extra.get("com.openai")
        if "com.openai" in extra and not isinstance(inline, dict):
            raise Invalid("com.openai_not_object")
        settings = inline if isinstance(inline, dict) else (overlay or {})
        origin = "inline" if isinstance(inline, dict) else "compatibility_overlay" if overlay is not None else "none"
        for k in root.keys() - {"$schema", "name", "version", "description", "author", "homepage", "repository", "license", "keywords", "extensions"}:
            warnings.append(f"portable_field_not_checked:{k}")
        if overlay is not None and inline is not None:
            # The entire fallback is ignored. It is not merged with inline fields.
            ignored_overlay = True
        else:
            ignored_overlay = False
    else:
        manifest = settings = overlay
        origin = "compatibility"
        ignored_overlay = False
    name = manifest.get("name")
    if not isinstance(name, str) or not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name) or len(name)>64:
        errors.append("local_safe_plugin_name_required")
    for key in ("version", "description"):
        if key in manifest and (not isinstance(manifest[key], str) or not manifest[key].strip()):
            errors.append(f"plugin_{key}_invalid")
    for key in settings.keys() - {"$schema", "name", "version", "description", "author", "homepage", "repository", "license", "keywords", "skills", "mcpServers", "apps", "hooks", "interface"}:
        warnings.append(f"openai_field_not_checked:{key}")
    if not portable:
        if "skills" not in settings:
            warnings.append("compatibility_skills_declaration_missing")
        elif settings["skills"] not in ("./skills/", "./skills"):
            errors.append("compatibility_skills_path_must_be_root_skills")
    # Fixed portable components take precedence over legacy skills/mcpServers.
    fields = ("apps", "hooks") if portable else ("apps", "hooks", "mcpServers")
    for key in fields:
        if key not in settings:
            continue
        value = settings[key]
        if not isinstance(value, str):
            warnings.append(f"component_config_not_checked:{key}"); continue
        if not value.startswith("./"):
            errors.append(f"component_root_prefix_required:{key}"); continue
        rel = value[2:];safe_path(rel)
        if rel not in data:
            errors.append(f"missing_component:{key}:{rel}")
        else:
            if rel.endswith(".json"):
                json_mapping(data[rel], rel)
            warnings.append(f"component_contract_not_checked:{key}")
    if portable and "mcp.json" in data:
        json_mapping(data["mcp.json"], "mcp.json")
        warnings.append("portable_mcp_contract_not_checked")
    ui = settings.get("interface", {})
    if not isinstance(ui, dict):
        errors.append("plugin_interface_not_object")
    else:
        strings = {"displayName", "shortDescription", "longDescription", "developerName", "category", "websiteURL", "privacyPolicyURL", "termsOfServiceURL", "brandColor", "logo", "composerIcon"}
        arrays = {"capabilities", "defaultPrompt", "screenshots"}
        for key, value in ui.items():
            if key not in strings | arrays:
                warnings.append(f"plugin_interface_field_not_checked:{key}");continue
            if key in strings and (not isinstance(value, str) or not value.strip()):
                errors.append(f"plugin_interface_string_required:{key}");continue
            if key in arrays and (not isinstance(value, list) or any(not isinstance(x,str) or not x.strip() for x in value)):
                errors.append(f"plugin_interface_strings_required:{key}");continue
            if key == "brandColor" and not re.fullmatch(r"#[a-fA-F0-9]{6}", value):
                errors.append("plugin_brand_color_format")
            if key in {"logo", "composerIcon", "screenshots"}:
                for item in value if key == "screenshots" else [value]:
                    if not item.startswith("./"):
                        errors.append(f"branding_root_prefix_required:{key}");continue
                    rel = item[2:];safe_path(rel)
                    if key == "screenshots":
                        if rel not in data:errors.append(f"missing_screenshot:{rel}")
                        else:warnings.append(f"screenshot_portal_limits_not_checked:{rel}")
                    else:image_check(rel, data, errors, warnings, image_checks)
    folders = sorted({p.split("/")[1] for p in data if p.startswith("skills/") and len(p.split("/"))>=2})
    identities, skill_results, skill_data = [], {}, {}
    for folder in folders:
        prefix = f"skills/{folder}/"
        files = {p[len(prefix):]:b for p,b in data.items() if p.startswith(prefix)}
        if folder.startswith("."):
            errors.append(f"hidden_skill_directory:{folder}")
        if not files or "SKILL.md" not in files:
            errors.append(f"SKILL.md_missing:{folder}");continue
        if any(p.endswith("/SKILL.md") for p in files):
            errors.append(f"nested_skill_manifest:{folder}")
        try:
            sr = check_skill(files, folder)
        except Blocked:
            raise
        except Exception as exc:
            errors.append(f"invalid_skill:{folder}:{exc}");continue
        identity = sr["identity"]
        skill_results[folder] = sr
        identities.append(identity)
        errors.extend(f"{folder}:{x}" for x in sr["errors"])
        warnings.extend(f"{folder}:{x}" for x in sr["warnings"])
        image_checks.extend({**c, "path": prefix+c["path"]} for c in sr["target_compatibility"]["inspections"])
        if isinstance(identity, str):
            if identity in skill_data:errors.append(f"duplicate_skill_identity:{identity}")
            skill_data[identity] = files
            if isinstance(name,str) and len(name+":"+identity)>64:errors.append(f"combined_identity_too_long:{identity}")
    if not identities:
        errors.append("conversion_requires_actual_skills")
    if expected is None:
        warnings.append("expected_skill_set_not_supplied:completeness_not_checked")
    else:
        if len(set(expected))!=len(expected):errors.append("duplicate_expected_identity")
        actual = {x for x in identities if isinstance(x,str)}
        for n in sorted(set(expected)-actual):errors.append(f"expected_skill_missing:{n}")
        for n in sorted(actual-set(expected)):errors.append(f"unexpected_skill:{n}")
    source_results = {}
    change_map = changes or {}
    if not isinstance(change_map, dict):raise Invalid("changes_not_object")
    used_changes = set()
    for identity, source in (sources or {}).items():
        original = snapshot(source)
        source_name = frontmatter(original.get("SKILL.md", b""))[0].get("name")
        if source_name != identity:
            errors.append(f"source_identity_mismatch:{identity}")
        target = skill_data.get(identity, {})
        diffs, approved = [], []
        for rel in sorted(set(original)|set(target)):
            if original.get(rel)==target.get(rel):continue
            change_key = identity+"/"+rel
            record = change_map.get(change_key)
            before = sha(original[rel]) if rel in original else None
            after = sha(target[rel]) if rel in target else None
            if (isinstance(record,dict) and record.get("before")==before and record.get("after")==after
                    and isinstance(record.get("reason"),str) and record["reason"].strip()):
                approved.append(change_key);used_changes.add(change_key)
            else:
                diffs.append(rel);errors.append(f"source_parity_mismatch:{identity}:{rel}")
        source_results[identity] = {"status":"FAIL" if diffs else "PASS",
                                    "file_count":len(target),"authorized_changes":approved,"unapproved":diffs}
    for k in set(change_map)-used_changes:errors.append(f"unused_or_invalid_change:{k}")
    if sources and expected is not None:
        for n in set(expected)-set(sources):warnings.append(f"source_parity_not_checked:{n}")
    result=report(data,errors,warnings,"LOCAL_SKILLS_ONLY_PLUGIN_SUBSET",image_checks)
    result.update(format="portable" if portable else "compatibility", settings_source=origin,
                  ignored_overlay=ignored_overlay, packaging_root=packaging_root,identity=name,
                  skills=skill_results, expected_skills=expected, source_parity=source_results,
                  directory_submission="NOT_RUN", dependency_semantics="NOT_RUN")
    return result,data


def zip_data(data: dict[str,bytes], prefix: str="") -> bytes:
    if prefix:
        if not prefix.endswith("/"):
            raise Invalid("zip_prefix_requires_directory_separator")
        safe_path(prefix[:-1])
    final_names = [prefix+name for name in data]
    check_names(final_names)
    buf=io.BytesIO()
    with zipfile.ZipFile(buf,"w",compression=zipfile.ZIP_DEFLATED,compresslevel=9) as z:
        for rel,content in sorted(data.items()):
            info=zipfile.ZipInfo(prefix+rel,(2026,1,1,0,0,0))
            info.create_system=3;info.external_attr=(stat.S_IFREG|0o644)<<16
            info.compress_type=zipfile.ZIP_DEFLATED
            z.writestr(info,content)
    raw=buf.getvalue()
    if len(raw) > MAX_TOTAL:
        raise Invalid("local_archive_size_limit")
    with zipfile.ZipFile(io.BytesIO(raw)) as z:
        actual = checked_archive(z)
        if {p.removeprefix(prefix):b for p,b in actual.items()} != data:
            raise Invalid("roundtrip_failed")
    return raw


def build(root: Path, output: Path) -> dict:
    output=new_destination(output,root)
    result,data=validate(root)
    if result["packaging_status"]!="PASS":return result
    prefix = canonical_root(root).name
    safe_path(prefix)
    raw=zip_data(data,prefix+"/")
    if snapshot(root)!=data:raise Invalid("source_changed_during_build")
    write_new(output,raw)
    try:
        actual = archive_bytes(output)
        if {p.removeprefix(prefix+"/"):b for p,b in actual.items()} != data or output.read_bytes() != raw:
            raise Invalid("written_archive_mismatch")
    except Exception:
        output.unlink(missing_ok=True); raise
    result.update(archive={"path":str(output),"bytes":len(raw),"sha256":sha(raw)},roundtrip="PASS",artifact_created=True)
    return result


def build_plugin(root: Path, output: Path, expected=None, sources=None, changes=None) -> dict:
    output=new_destination(output,root)
    result,data=validate_plugin(root,expected,sources,changes)
    if result["packaging_status"]!="PASS":return result
    raw=zip_data(data)
    if snapshot(root)!=data:raise Invalid("source_changed_during_build")
    write_new(output,raw)
    # Validate the final serialized bytes, not just the working tree.
    try:
        final,_=validate_plugin(output,expected,sources,changes)
        if final["packaging_status"] != "PASS" or output.read_bytes() != raw:
            raise Invalid("final_plugin_validation_failed")
    except Exception:
        output.unlink(missing_ok=True); raise
    final.update(archive={"path":str(output),"bytes":len(raw),"sha256":sha(raw)},roundtrip="PASS",artifact_created=True)
    return final


def main() -> int:
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument("operation",choices=["inspect","extract","validate","build","validate-plugin","build-plugin"])
    parser.add_argument("source",type=Path)
    parser.add_argument("destination",type=Path,nargs="?")
    parser.add_argument("--expect",action="append",help="Expected Skill identity; repeat for bundles.")
    parser.add_argument("--source",dest="sources",action="append",default=[],metavar="NAME=PATH",help="Original Skill directory for byte parity; repeat.")
    parser.add_argument("--changes",type=Path,help="Explicit reviewed before/after SHA256 + reason map.")
    args=parser.parse_args()
    try:
        writes={"extract","build","build-plugin"}
        if (args.operation in writes)!=(args.destination is not None):raise Invalid("destination_required_only_for_writes")
        if args.operation not in {"validate-plugin","build-plugin"} and (args.expect or args.sources or args.changes):raise Invalid("plugin_options_on_skill_operation")
        sources={}
        for x in args.sources:
            n,sep,path=x.partition("=")
            if not sep or not n or not path or n in sources:raise Invalid("source_argument_requires_unique_NAME=PATH")
            sources[n]=Path(path)
        expected=args.expect if args.expect is not None else list(sources) if sources else None
        changes=json_mapping(args.changes.read_bytes(),"changes") if args.changes else None
        if args.operation in {"inspect","extract"}:
            data=archive_bytes(args.source)
            if args.operation=="extract":
                dst=new_destination(args.destination)
                dst.mkdir(exist_ok=False)
                try:
                    for rel,content in data.items():
                        p=dst.joinpath(*safe_path(rel).parts);p.parent.mkdir(parents=True,exist_ok=True)
                        write_new(p,content)
                except Exception:
                    shutil.rmtree(dst);raise
            result=report(data,[],[],"ARCHIVE_SAFETY_AND_CRC")
            result["artifact_created"] = args.operation == "extract"
        elif args.operation=="validate":result,_=validate(args.source)
        elif args.operation=="build":result=build(args.source,args.destination)
        elif args.operation=="validate-plugin":result,_=validate_plugin(args.source,expected,sources,changes)
        else:result=build_plugin(args.source,args.destination,expected,sources,changes)
    except Blocked as exc:result={"status":"BLOCKED","packaging_status":"BLOCKED","artifact_created":False,"error":str(exc)}
    except Exception as exc:result={"status":"FAIL","packaging_status":"FAIL","artifact_created":False,"error":f"{type(exc).__name__}: {exc}"}
    print(json.dumps(result,ensure_ascii=False,indent=2))
    return {"PASS":0,"FAIL":1,"BLOCKED":2,"REVIEW":3}[result["status"]]

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