#!/usr/bin/env python3 """migrate.py — bring an existing ECHO vault up to the plugin's current schema. Reads the marker's schema_version and applies each intervening migration in order. Migrations are idempotent and additive; every destructive step (DELETE/move) is gated behind --apply AND printed first. Default mode is a DRY-RUN plan. Cross-platform: pure Python via echo.py. Usage: migrate.py # print the migration plan (no changes) migrate.py --apply # perform the migration (moves/deletes included) Env: ECHO_BASE, ECHO_KEY (via echo.py). """ from __future__ import annotations import argparse import json import sys from pathlib import Path SCRIPT_DIR = Path(__file__).resolve().parent sys.path.insert(0, str(SCRIPT_DIR)) import echo # noqa: E402 CURRENT_SCHEMA = 3 def get_text(path: str) -> str | None: status, body = echo.request("GET", echo.vault_url(path)) if status == 404: return None echo.check(status, body, f"get {path}") return body.decode(errors="replace") def list_files(path: str) -> list[str]: if not path.endswith("/"): path += "/" status, body = echo.request("GET", echo.vault_url(path)) if status == 404: return [] echo.check(status, body, f"ls {path}") try: payload = json.loads(body) except json.JSONDecodeError: return [] return [entry for entry in payload.get("files", []) if not entry.endswith("/")] def put_text(path: str, text: str) -> None: status, body = echo.request("PUT", echo.vault_url(path), data=text.encode(), headers={"Content-Type": "text/markdown"}) echo.check(status, body, f"put {path}") def delete(path: str) -> None: status, body = echo.request("DELETE", echo.vault_url(path)) if status != 404: echo.check(status, body, f"delete {path}") def move(src: str, dst: str) -> None: text = get_text(src) if text is None: return put_text(dst, text) delete(src) def do_or_show(apply: bool, desc: str, func=None) -> None: if apply: print(f"migrate: APPLY {desc}") if func: func() else: print(f"migrate: PLAN {desc}") def marker_schema() -> int: marker = get_text("_agent/echo-vault.md") if marker is None: print("migrate: marker missing — vault not bootstrapped. Run bootstrap.py, not migrate.py.") raise SystemExit(3) for line in marker.splitlines(): if line.startswith("schema_version:"): try: return int(line.split(":", 1)[1].strip()) except ValueError: return 0 return 0 def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Migrate ECHO vault schema") parser.add_argument("--apply", action="store_true") args = parser.parse_args(argv) start = marker_schema() print(f"migrate: vault schema_version={start}, plugin schema={CURRENT_SCHEMA} " f"{'(APPLY)' if args.apply else '(dry-run)'}") if start >= CURRENT_SCHEMA: print("migrate: up to date — nothing to do.") return 0 if start < 1: print("migrate: [0->1] retire in-vault control docs (CLAUDE/BOOTSTRAP/STRUCTURE/index.md)") for filename in ["CLAUDE.md", "BOOTSTRAP.md", "STRUCTURE.md", "index.md"]: if get_text(filename) is not None: do_or_show(args.apply, f"delete vault/{filename} (back it up outside the vault first)", lambda f=filename: delete(f)) print("migrate: [0->1] reminder: scrub dangling control-doc [[links]] from Related sections.") if start < 2: print("migrate: [1->2] fold reviews/ into journal/ and _agent/health/") for filename in list_files("reviews/weekly"): if filename.endswith(".md"): dst = f"journal/weekly/{filename.replace('-review.md', '.md')}" do_or_show(args.apply, f"move reviews/weekly/{filename} -> {dst}", lambda f=filename, d=dst: move(f"reviews/weekly/{f}", d)) for filename in list_files("reviews/monthly"): if filename.endswith(".md"): dst = f"_agent/health/{filename}" if filename.endswith("vault-health.md") else f"journal/monthly/{filename}" do_or_show(args.apply, f"move reviews/monthly/{filename} -> {dst}", lambda f=filename, d=dst: move(f"reviews/monthly/{f}", d)) for period in ["quarterly", "annual"]: for filename in list_files(f"reviews/{period}"): if filename.endswith(".md"): dst = f"journal/{period}/{filename}" do_or_show(args.apply, f"move reviews/{period}/{filename} -> {dst}", lambda p=period, f=filename, d=dst: move(f"reviews/{p}/{f}", d)) print("migrate: [1->2] reminder: update inbound [[reviews/...]] wikilinks manually.") if start < 3: print("migrate: [2->3] add the entity-index folder (cross-link / recall feature)") if get_text("_agent/index/README.md") is None: do_or_show(args.apply, "create _agent/index/README.md", lambda: put_text("_agent/index/README.md", "# index\n\nMachine-maintained entity index. See the echo-memory plugin.\n")) print("migrate: [2->3] then run `sweep.py --apply` to build entities.json and symmetrize links.") do_or_show(args.apply, f"set _agent/echo-vault.md schema_version -> {CURRENT_SCHEMA}", lambda: echo.cmd_fm("_agent/echo-vault.md", "schema_version", str(CURRENT_SCHEMA))) if args.apply: print(f"migrate: migration complete -> schema {CURRENT_SCHEMA}. Run sweep.py --apply, then vault_lint.py.") else: print("migrate: dry-run only. Re-run with --apply to perform the moves/deletes above.") return 0 if __name__ == "__main__": raise SystemExit(main())