← Files PMCARCHIVED FILE
skills/curate-project-memory/scripts/export_project.py
5.32 KB · Oct 3, 2026 · 06:31 UTC
#!/usr/bin/env python3
"""Create a portable Project Memory snapshot without network access."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
from datetime import datetime, timezone
from pathlib import Path
FRONTMATTER = re.compile(r"\A---\s*\n(.*?)\n---\s*\n", re.DOTALL)
PROPERTY = re.compile(r"^([A-Za-z_][\w-]*):\s*(.*)$")
SECRET_PATTERNS = (
re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
re.compile(r"\b(?:api[_-]?key|access[_-]?token|client[_-]?secret|password)\s*[:=]\s*[^\s]+", re.I),
re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b"),
)
def properties(text: str) -> dict[str, str]:
match = FRONTMATTER.search(text)
if not match:
return {}
result: dict[str, str] = {}
for line in match.group(1).splitlines():
item = PROPERTY.match(line)
if item:
result[item.group(1)] = item.group(2).strip().strip("'\"")
return result
def safe_include(root: Path, value: str) -> Path:
relative = Path(value.replace("\\", "/"))
if relative.is_absolute():
raise ValueError("include paths must be project-relative")
resolved = (root / relative).resolve()
try:
resolved.relative_to(root)
except ValueError as error:
raise ValueError("include path escapes the project folder") from error
if not resolved.is_file() or resolved.suffix.casefold() != ".md":
raise ValueError(f"included note does not exist: {value}")
return resolved
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("project_folder", type=Path)
parser.add_argument("output_file", type=Path)
parser.add_argument("--mode", required=True, choices=("onboarding", "decision-log", "audit", "context"))
parser.add_argument("--include", action="append", default=[])
parser.add_argument("--force", action="store_true")
args = parser.parse_args()
root = args.project_folder.resolve()
output = args.output_file.resolve()
if not root.is_dir():
parser.error(f"project folder does not exist: {root}")
if output.exists() and not args.force:
parser.error(f"output already exists: {output}")
available = [
path for path in sorted(root.rglob("*.md"))
if output != path.resolve() and not any(part.casefold() in {".obsidian", "inbox"} for part in path.parts)
]
records = [(path, path.read_text(encoding="utf-8", errors="replace")) for path in available]
if any(pattern.search(text) for _, text in records for pattern in SECRET_PATTERNS):
parser.error("possible credential-like content detected; run the health review before exporting")
selected: list[tuple[Path, str]] = []
if args.mode == "audit":
selected = records
elif args.mode == "decision-log":
selected = [(path, text) for path, text in records if properties(text).get("type") == "decision" and properties(text).get("status") in {"accepted", "superseded"}]
elif args.mode == "onboarding":
for path, text in records:
props = properties(text)
if path.name.casefold() in {"project.md", "current state.md", "handoff.md"} or (props.get("type") == "decision" and props.get("status") == "accepted"):
selected.append((path, text))
else:
if not args.include:
parser.error("context mode requires at least one inspected --include note")
wanted = {safe_include(root, value) for value in args.include}
wanted.update(path for path, _ in records if path.name.casefold() in {"project.md", "current state.md"})
selected = [(path, text) for path, text in records if path in wanted]
created_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
output.parent.mkdir(parents=True, exist_ok=True)
if args.mode == "audit":
manifest = {
"format": "project-memory-audit-v1",
"created_at": created_at,
"snapshot": True,
"notes": [
{
"path": path.relative_to(root).as_posix(),
"sha256": hashlib.sha256(text.encode("utf-8")).hexdigest(),
"type": properties(text).get("type") or None,
"project_id": properties(text).get("project_id") or None,
"knowledge_id": properties(text).get("knowledge_id") or None,
"status": properties(text).get("status") or None,
"updated": properties(text).get("updated") or None,
}
for path, text in selected
],
}
output.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
else:
lines = [
f"# Project Memory {args.mode.replace('-', ' ').title()} Export",
"",
f"Created: {created_at}",
"",
"> Snapshot only. Canonical knowledge remains in the Project Memory vault.",
"",
]
for path, text in selected:
lines.extend(["---", "", f"Source: `{path.relative_to(root).as_posix()}`", "", text.rstrip(), ""])
output.write_text("\n".join(lines), encoding="utf-8")
print(json.dumps({"mode": args.mode, "output_file": str(output), "notes_exported": len(selected), "snapshot": True}, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
SHA-256: 6217a7d40c80eb2c09f25f6bfe584686856dac3760f253ae0a40eb040b6d8f11