Files

225 lines
11 KiB
Python

#!/usr/bin/env python3
"""Install the checked-out root Skill, or verify it, without another Skill."""
import argparse
import copy
from datetime import datetime, timezone
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import uuid
if sys.version_info < (3, 12):
raise SystemExit("Use Python 3.12+ to install the complete native workflow.")
import tomllib
def sha(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def roster(root):
files = {}
for path in sorted(root.rglob("*")):
relative = path.relative_to(root)
if any(part.startswith(".") or part == "__pycache__" for part in relative.parts):
continue
if path.is_symlink():
raise ValueError(f"Source symlink is unsupported: {relative}")
if path.is_file() and path.suffix != ".pyc":
files[relative.as_posix()] = sha(path)
return files
def safe_target(path):
path = Path(os.path.abspath(path.expanduser()))
if any(item.is_symlink() for item in (path, *path.parents)):
raise ValueError(f"Destination symlink is unsupported: {path}")
return path
def compiler_integrity(source):
provenance = json.loads((source / "runtime/provenance.json").read_text())
root = source / "runtime/ppt-master"
for item in provenance["files"]:
path = root / item["path"]
if not path.resolve().is_relative_to(root) or sha(path) != item["sha256"]:
raise ValueError(f"Compiler integrity failed: {item['path']}")
return len(provenance["files"])
def runtime_check(python, source):
code = """
import importlib.metadata, json, pathlib, re, sys
if sys.version_info < (3, 12):
raise RuntimeError('Native runtime needs Python 3.12+')
source = pathlib.Path(sys.argv[1])
versions = {}
for line in (source / 'runtime/requirements.txt').read_text().splitlines():
if not line or line.startswith('#'): continue
name, expected = line.split('==')
actual = importlib.metadata.version(name)
if actual != expected: raise RuntimeError(f'{name}: expected {expected}, got {actual}')
versions[name] = actual
sys.path.insert(0, str(source / 'scripts'))
from native_export import load_compiler
load_compiler()
print(json.dumps({'python': sys.version.split()[0], 'packages': versions, 'compiler_loaded': True}))
"""
result = subprocess.run([str(python), "-c", code, str(source)],
text=True, capture_output=True, check=True)
return json.loads(result.stdout)
def config_plan(codex, target):
path = safe_target(codex / "config.toml")
before = path.read_text(encoding="utf-8") if path.is_file() else ""
data = tomllib.loads(before)
entries = data.get("skills", {}).get("config", [])
root_entry = str(target / "SKILL.md")
nested = str(target / "runtime/ppt-master/SKILL.md")
if any(item.get("path") == root_entry and item.get("enabled") is False for item in entries):
raise ValueError("Root Skill is disabled in Codex; preserve that decision and resolve it explicitly.")
matches = [item for item in entries if item.get("path") == nested]
if matches:
if len(matches) != 1 or matches[0].get("enabled") is not False:
raise ValueError("Conflicting nested attribution entry; resolve this exact Codex entry explicitly.")
return path, before, before
addition = f"\n[[skills.config]]\npath = {json.dumps(nested, ensure_ascii=False)}\nenabled = false\n"
after = before + addition
parsed = tomllib.loads(after)
expected = copy.deepcopy(data)
expected.setdefault("skills", {}).setdefault("config", []).append({"path": nested, "enabled": False})
if parsed != expected:
raise ValueError("Codex configuration change affected unrelated fields.")
return path, before, after
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--dest", type=Path, required=True, help="Exact installed Skill directory")
parser.add_argument("--codex-home", type=Path, help="Exclude only the nested attribution Skill")
parser.add_argument("--venv", type=Path, help="Dedicated Python 3.12+ virtual environment")
parser.add_argument("--prepare-runtime", action="store_true", help="Create venv and install pinned packages")
parser.add_argument("--replace", action="store_true", help="Back up and replace an existing Skill")
parser.add_argument("--backup-root", type=Path, help="Backup root outside Skill discovery directories")
parser.add_argument("--check", action="store_true", help="Read-only payload, configuration and runtime check")
args = parser.parse_args()
source = Path(__file__).resolve().parents[1]
target = safe_target(args.dest)
if target == source or target.is_relative_to(source) or source.is_relative_to(target):
raise ValueError("Source and installed destination must be separate directories.")
if target.name != "ppt-template-workflow":
raise ValueError("Destination must end in ppt-template-workflow.")
version_match = re.search(r'^ version: "([0-9.]+)"$', (source / "SKILL.md").read_text(), re.M)
if not version_match:
raise ValueError("Missing root Skill version.")
files = roster(source)
compiler_files = compiler_integrity(source)
if args.prepare_runtime and (args.venv is None or args.check):
raise ValueError("--prepare-runtime requires --venv and cannot be combined with --check.")
codex = safe_target(args.codex_home) if args.codex_home else None
config = config_plan(codex, target) if codex else None
if args.check:
if not target.is_dir() or roster(target) != files:
raise ValueError("Installed payload differs from the selected source.")
compiler_integrity(target)
if config and config[1] != config[2]:
raise ValueError("Nested compiler attribution entry has not been disabled.")
elif target.exists() and not args.replace:
raise FileExistsError("Destination exists; an authorized upgrade needs --replace (with backup).")
elif target.exists() and not target.is_dir():
raise ValueError("Existing destination is not a Skill directory.")
runtime = None
if args.venv:
venv = safe_target(args.venv)
if venv == target or venv.is_relative_to(target) or venv == source or venv.is_relative_to(source):
raise ValueError("Runtime must be outside source and installed Skill.")
python = safe_target(venv / ("Scripts/python.exe" if os.name == "nt" else "bin/python")) if os.name == "nt" else venv / "bin/python"
# POSIX venv interpreters are normally symlinks to their base Python.
if args.prepare_runtime:
if not python.is_file():
subprocess.run([sys.executable, "-m", "venv", str(venv)], check=True)
subprocess.run([str(python), "-m", "pip", "install", "--disable-pip-version-check",
"-r", str(source / "runtime/requirements.txt")], check=True)
runtime = {"executable": str(python), **runtime_check(python, source)}
commit = subprocess.run(["git", "-C", str(source), "rev-parse", "HEAD"],
capture_output=True, text=True)
dirty = subprocess.run(["git", "-C", str(source), "status", "--porcelain"],
capture_output=True, text=True) if commit.returncode == 0 else None
receipt = {"schema_version": 1, "skill": "ppt-template-workflow",
"version": version_match[1], "source_commit": commit.stdout.strip() if commit.returncode == 0 else None,
"source_dirty": bool(dirty.stdout) if dirty and dirty.returncode == 0 else None,
"installed_path": str(target), "payload": files, "compiler_files": compiler_files,
"runtime": runtime, "status": "native-ready" if runtime else "files-only",
"codex_attribution_disabled": bool(config), "checked": args.check}
if not args.check:
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + uuid.uuid4().hex[:8]
backup_root = safe_target(args.backup_root or
((codex / "backups/ppt-template-workflow") if codex else target.parent.parent / "skill-backups"))
if (backup_root == target or backup_root.is_relative_to(target) or backup_root.is_relative_to(target.parent)
or backup_root == source or backup_root.is_relative_to(source)):
raise ValueError("Backup must be outside source and Skill discovery directory.")
target.parent.mkdir(parents=True, exist_ok=True)
stage = Path(tempfile.mkdtemp(prefix=".ppt-install-", dir=target.parent))
backup = backup_root / stamp / "ppt-template-workflow"
moved = False
config_written = False
config_backup = None
try:
for relative in files:
destination = stage / relative
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source / relative, destination)
if roster(stage) != files:
raise ValueError("Staged installation hash mismatch.")
if target.exists() or (config and config[1] != config[2]):
backup.parent.mkdir(parents=True, exist_ok=False, mode=0o700)
if target.exists():
target.rename(backup)
moved = True
if config and config[1] != config[2]:
path, before, after = config
if (path.read_text(encoding="utf-8") if path.is_file() else "") != before:
raise ValueError("Codex configuration changed during installation.")
config_backup = backup.parent / "config.toml.before"
config_backup.write_text(before, encoding="utf-8")
config_backup.chmod(0o600)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(after, encoding="utf-8")
if not before:
path.chmod(0o600)
config_written = True
receipt.update(backup=str(backup) if moved else None,
config_backup=str(config_backup) if config_backup else None,
installed_at=datetime.now(timezone.utc).isoformat())
(stage / ".installation.json").write_text(json.dumps(receipt, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
stage.rename(target)
except Exception:
if config_written:
config[0].write_text(config[1], encoding="utf-8")
if moved and not target.exists():
backup.rename(target)
raise
finally:
if stage.exists():
shutil.rmtree(stage)
summary = {key: value for key, value in receipt.items() if key != "payload"}
summary["payload_files"] = len(files)
print(json.dumps(summary, ensure_ascii=False, indent=2))
if __name__ == "__main__":
if sys.version_info < (3, 12):
raise SystemExit("Use Python 3.12+ to install the complete native workflow.")
try:
main()
except (OSError, ValueError, subprocess.CalledProcessError) as error:
raise SystemExit(f"Installation failed: {error}")