"""Alexandria CLI — the dev/power path for installing and updating a vault.

The consumer-facing path is the agent-driven ``alexandria-install`` /
``alexandria-update`` skills (Claude UI, no terminal). This CLI is the same engine,
exposed for developers and power users.

Commands:
  init        Scaffold a new Alexandria vault from a config.json.
  update      Refresh Core from the installed software (render-only; Config + Content untouched).
  status      Show installed version, enabled modules, and Core drift.
  doctor    Validate config, modules, secrets, and the travel schedule.
  secrets   Show which credentials are needed + how to provision them per backend.
  enable    Enable a module.
  disable   Disable a module.
  install-launchd  Install + load the server-macos launchd jobs (host-side).
"""
from __future__ import annotations

import argparse
import datetime as dt
import json
import os
import re
import sys
from pathlib import Path

from . import __version__


def _not_yet(name):
    def handler(args):
        print(f"`alexandria {name}` is not implemented yet.")
        print("See README.md and the build plan for the intended behavior.")
        return 1
    return handler


def _print_problems(problems) -> int:
    """Print ERROR/WARN problem strings; return the count of ERRORs."""
    for p in problems:
        print("  " + p)
    return sum(1 for p in problems if p.startswith("ERROR"))


def _build_and_modules(ids):
    """The source-build identity (git commit) + the enabled/available module manifest for
    this render — fed to the renderer (for Agent/Version.md) and to render.lock, so the
    vault knows exactly what version + features it's running."""
    from . import manifest, modules as modmod
    build = manifest.source_build_info()
    enabled = []
    for i in ids:
        try:
            m = modmod.load_manifest(i)
        except OSError:
            m = {}
        enabled.append({
            "id": i, "name": m.get("name") or i, "version": m.get("version"),
            "description": m.get("description") or "",
            "provides": m.get("provides") or [],
        })
    enabled_ids = set(ids)
    available = [{"id": r.get("id"), "description": r.get("description") or ""}
                 for r in modmod.load_registry() if r.get("id") not in enabled_ids]
    return build, {"enabled": enabled, "available": available}


def _config_from_wizard(args):
    """Obtain a config via the technical config wizard — the inline question flow of
    ``alexandria install``, used by ``init`` when no ``--config`` is given. Uses ``--answers``
    (a JSON list of answers) if given, else the terminal. Returns the config dict, or None if
    it can't run / the user aborts."""
    from . import config_wizard
    if getattr(args, "answers", None):
        answers = json.loads(Path(args.answers).read_text(encoding="utf-8"))
        ask = config_wizard.scripted_ask(answers)
    elif sys.stdin.isatty():
        ask = None  # interactive terminal
    else:
        print("No --config given and stdin is not a TTY. Pass --config <config.json> "
              "(see config.example.json), run `alexandria init` in a terminal, or "
              "pass --answers <answers.json>.")
        return None
    try:
        return config_wizard.run_config_wizard(ask=ask)
    except config_wizard.WizardAborted as e:
        print(f"Setup aborted: {e}")
        return None


def _launch_claude_code(vault: Path) -> bool:
    """Best-effort: open Claude Code (the ``claude`` CLI) in the vault dir so the owner lands
    in a session where they can start the interview. macOS-only auto-open (osascript →
    Terminal); returns False if it can't, so the caller can print manual steps instead."""
    import shutil
    import subprocess
    if not shutil.which("claude") or sys.platform != "darwin":
        return False
    esc = str(vault).replace("\\", "\\\\").replace('"', '\\"')
    apple = ('tell application "Terminal"\n'
             "  activate\n"
             f'  do script "cd \\"{esc}\\" && claude"\n'
             "end tell")
    try:
        subprocess.run(["/usr/bin/osascript", "-e", apple], check=True,
                       stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        return True
    except (OSError, subprocess.CalledProcessError):
        return False


def cmd_interview(args) -> int:
    """Start the owner's personalization — *the user interview*. This does NOT run a Q&A
    itself: it opens the vault in Claude Code so the owner can do the conversational
    ``settings`` first-run pass (say "Hello"). The technical setup is ``alexandria install``."""
    vault = Path(getattr(args, "vault", None) or ".").expanduser()
    cfg_path = vault / "Agent" / "config.json"
    if not cfg_path.exists():
        print(f"Not an Alexandria vault: {vault} (no Agent/config.json). "
              "Pass --vault <path> to your vault.")
        return 1
    try:
        cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
    except (OSError, ValueError) as e:
        print(f"Could not read {cfg_path}: {e}")
        return 1
    agent = cfg.get("agent") or {}
    if agent.get("personalized"):
        print(f'This vault is already personalized ({agent.get("name") or "your agent"}). '
              'Open it and say "settings" to tune anything.')
    else:
        print("— Alexandria interview —")
        print('Time to make this vault yours. Open it in Claude Code and say "Hello" — '
              "I'll walk you through your name, my name, my personality and voice, your "
              "areas, and how you like your daily brief.")
    opened = False
    if not getattr(args, "no_open", False) and sys.stdout.isatty():
        opened = _launch_claude_code(vault)
    if opened:
        print("\nOpening your vault in Claude Code…")
    else:
        print(f'\nOpen your vault and start:  cd "{vault}" && claude   (then say "Hello")')
    return 0


def cmd_init(args) -> int:
    from . import config as cfgmod, modules as modmod, render, manifest
    vault = Path(args.vault).expanduser()
    if not args.config:
        config = _config_from_wizard(args)
        if config is None:
            return 1
    else:
        config = json.loads(Path(args.config).read_text(encoding="utf-8"))

    # Auto-fill the owner's timezone from the host if the config left it blank, so no
    # onboarding step has to ask for it (see config.detect_timezone).
    owner = config.get("owner")
    if isinstance(owner, dict) and not owner.get("timezone_default"):
        owner["timezone_default"] = cfgmod.detect_timezone()

    # Federation is core (§9.4): materialize the default federation block so every fresh
    # brain ships push-capable out of the box — never an identity, key, or peer, only the
    # relay address + safe defaults. Non-clobbering: an explicit block is kept as-is.
    cfgmod.ensure_federation_defaults(config)

    print("Validating config…")
    if _print_problems(cfgmod.validate(config)):
        print("\nFix the config errors above and retry.")
        return 1
    ids, modprob = modmod.resolve(cfgmod.enabled_modules(config))
    if _print_problems(modprob):
        return 1

    module_dirs = [modmod.template_dir(i) for i in ids]
    vault.mkdir(parents=True, exist_ok=True)
    build, minfo = _build_and_modules(ids)
    written = render.render_vault(vault, module_dirs, config, build=build, modules_info=minfo)

    # The vault's own config is the source of truth — write it into Agent/.
    (vault / "Agent").mkdir(parents=True, exist_ok=True)
    (vault / "Agent" / "config.json").write_text(
        json.dumps(config, indent=2) + "\n", encoding="utf-8")
    manifest.write_state(vault, written, config,
                         (config.get("alexandria") or {}).get("template_ref"),
                         stamp=args.stamp, build=build)

    # Give the vault a deterministic commit identity from config (the agent's name +
    # email) so the initial commit and the vault-sync job are attributed to the agent —
    # not left for the installer/agent to guess or for git to fall back to <user>@<host>.
    _set_vault_git_identity(vault, config)

    by_bucket = {}
    for w in written:
        by_bucket[w["bucket"]] = by_bucket.get(w["bucket"], 0) + 1
    print(f"\nRendered {len(written)} files into {vault}")
    print("  buckets: " + ", ".join(f"{k}={v}" for k, v in sorted(by_bucket.items())))
    print("  modules: " + ", ".join(ids))
    _maybe_vendor_engine_wheel(vault, ids)
    from . import banner
    print("\n" + banner.from_vault(vault))
    # Birth (docs/RELAY-CENSUS.md §4.1): key -> mailbox -> announce, fail-soft — an
    # offline install prints one line and still exits 0. A birth never fails an install.
    _announce_birth(config, channel="local")
    # The next-steps line must match what this config actually has: a local-only
    # brain (no remote, no runner) has no connector to wire and no secrets to set.
    if config.get("repo") or (config.get("automation") or {}).get("runner"):
        print("\nNext: connect Claude (GitHub connector), then run "
              "`alexandria doctor --vault <path>` and set up secrets (Agent/SECRETS.md). "
              "The Claude Desktop system prompt is pasted later, by the owner, after personalizing.")
    else:
        print("\nNext: run `alexandria doctor --vault <path>`, then hand the session to the "
              "owner to personalize (the settings first-run pass). Backup, connectors, and "
              "secrets are optional upgrades — nothing to set up now.")
    return 0


def _announce_birth(config, *, channel, force=True, quiet=False, relay_client=None):
    """The engine's fail-soft wrapper around ``federation.birth.announce`` (§7.2):
    every canal — init, the cron tick — reports in one line and never raises. Returns
    the announce result (or None when federation is unavailable). ``quiet`` keeps a
    silent skip silent (the tick path)."""
    try:
        from alexandria_vault.federation import birth
        res = birth.announce(config, relay_client, channel=channel, force=force)
    except Exception as e:  # noqa: BLE001 — a birth must not fail an install or a tick
        if not quiet:
            print(f"relay: could not announce this brain's birth ({e}) — it will "
                  "announce itself when served.")
        return None
    url = ((config.get("federation") or {}).get("relay_url")
           if isinstance(config.get("federation"), dict) else None)
    if res.get("announced"):
        fp = res.get("fingerprint") or ""
        if res.get("status") == 200 and channel == "local":
            # A fresh vault refreshing an existing record: this user account already
            # has a brain identity (one per state dir, shared by every vault under it),
            # so the census counts this machine once. Say so rather than imply a new row.
            print(f"relay: this account's brain identity (fingerprint {fp[:12]}…) was "
                  f"already announced to {url} — refreshed; vaults on one account share "
                  "one identity, so the census counts them as one brain.")
        else:
            print(f"relay: announced this brain to {url} (fingerprint {fp[:12]}…) — "
                  f"{'refreshed' if res.get('status') == 200 else 'first birth'}.")
    elif res.get("skipped"):
        if not quiet and res["skipped"] == "disabled":
            print("relay: birth announcement is off (federation.announce = false).")
    elif not quiet:
        print("relay unreachable — the brain will announce itself when served.")
    return res


def _set_vault_git_identity(vault, config) -> None:
    """Configure the vault repo's *local* git identity from config — the agent's name +
    email (collected in the interview). This makes the initial commit and the vault-sync
    job commit as the agent, instead of forcing the installer to guess an identity (or
    git falling back to ``<user>@<host>``). No-ops when the vault isn't a git repo yet or
    the repo already has an identity, so it never clobbers a deliberate choice."""
    import subprocess
    if not (vault / ".git").exists():
        return
    agent = config.get("agent") or {}
    name, email = agent.get("name"), agent.get("email")
    if not (name and email):
        return

    def _has(key: str) -> bool:
        r = subprocess.run(["git", "-C", str(vault), "config", "--local", "--get", key],
                           capture_output=True, text=True)
        return bool(r.stdout.strip())

    if _has("user.name") or _has("user.email"):
        return
    for key, val in (("user.name", name), ("user.email", email)):
        subprocess.run(["git", "-C", str(vault), "config", key, val], check=False)
    print(f"Set vault git identity: {name} <{email}> (from config.agent)")


def _doctor_server_macos(vault) -> list:
    """Host-side health for a server-macos vault: the off-repo secrets env file (+ perms),
    the installed launchd plists and whether they're loaded, and how recently the hourly
    sweep ran. Returns status lines — a ``WARN:`` prefix flags an issue; others are info."""
    import os, subprocess, time
    home = Path.home()
    out = []

    # Off-repo secrets env file — mirror the wrappers' search order (see digest.sh).
    from . import secrets_walkthrough as walk
    candidates = walk.env_candidates()
    envfile = next((p for p in candidates if p and os.path.isfile(p)), None)
    if not envfile:
        out.append("WARN: no off-repo secrets env file — create it with "
                   "`alexandria secrets --set`. Digests + listener fail without it.")
    elif os.stat(envfile).st_mode & 0o077:
        out.append(f"WARN: {envfile} is {oct(os.stat(envfile).st_mode & 0o777)}; "
                   f"tighten it — chmod 600 {envfile}")
    else:
        out.append(f"env file: {envfile} (perms 600)")

    # launchd plists: installed in ~/Library/LaunchAgents, and loaded.
    jobs = _plan_launchd(vault)
    if not jobs:
        out.append("WARN: no launchd templates in the vault — is server-macos rendered?")
    else:
        try:
            r = subprocess.run(["launchctl", "list"], capture_output=True, text=True)
            listed = r.stdout if r.returncode == 0 else None
        except OSError:
            listed = None  # launchctl unavailable — not running on the host
        for j in jobs:
            label = j["name"][:-6] if j["name"].endswith(".plist") else j["name"]
            if not j["target"].exists():
                out.append(f"WARN: {label} not installed — run "
                           f"`alexandria install-launchd --vault {vault}`.")
            elif listed is None:
                out.append(f"{label}: installed (run on the host to check it's loaded)")
            elif label in listed:
                out.append(f"{label}: installed + loaded")
            else:
                out.append(f"WARN: {label} installed but not loaded — re-run "
                           f"`alexandria install-launchd --vault {vault}`.")

    # Last sweep — prefer the heartbeat (runner-status.json: explicit run time + per-task
    # outcomes), else fall back to the sweep-state.json mtime.
    statedir = Path(os.environ.get(
        "SWEEP_STATE", str(home / "Obsidian" / "secrets" / "sweep-state.json"))).parent
    hb, state = statedir / "runner-status.json", statedir / "sweep-state.json"
    logs = "~/Library/Logs/alexandria/"
    if hb.exists():
        age = (time.time() - hb.stat().st_mtime) / 60
        try:
            data = json.loads(hb.read_text())
        except Exception:
            data = {}
        errs = data.get("errors") or []
        if age > 90:
            out.append(f"WARN: last sweep {age / 60:.1f}h ago (expected hourly) — check "
                       f"`launchctl list | grep alexandria` and {logs}.")
        elif errs:
            out.append(f"WARN: last sweep {int(age)}m ago but reported {len(errs)} error(s): {errs[0]}")
        else:
            out.append(f"last sweep: {int(age)}m ago, all tasks ok")
    elif state.exists():
        age = (time.time() - state.stat().st_mtime) / 60
        if age <= 90:
            out.append(f"last sweep: {int(age)}m ago (hourly — healthy)")
        else:
            out.append(f"WARN: last sweep {age / 60:.1f}h ago (expected hourly) — check "
                       f"`launchctl list | grep alexandria` and {logs}.")
    else:
        out.append("WARN: no runner-status.json / sweep-state.json yet — the hourly sweep "
                   "hasn't run (check again after the next top-of-hour).")

    # vault-sync (every ~10 min) + the optional IMAP listener — their own heartbeats.
    for name, fname, stale in (("sync", "sync-status.json", 25),
                               ("listener", "listener-status.json", 40)):
        f = statedir / fname
        if not f.exists():
            if name == "sync":
                out.append("WARN: no sync-status.json — vault-sync hasn't run (git sync may be down).")
            continue  # the listener is optional (needs an inbox module + INBOX_LISTENER_ENABLE)
        age = (time.time() - f.stat().st_mtime) / 60
        try:
            info = json.loads(f.read_text())
        except Exception:
            info = {}
        if age > stale:
            out.append(f"WARN: {name} last ran {int(age)}m ago (expected < {stale}m) — may be stuck.")
        elif info.get("ok") is False:
            out.append(f"WARN: {name} {int(age)}m ago but not ok: {info.get('note', '')}")
        else:
            out.append(f"{name}: {int(age)}m ago, ok")
    return out


def cmd_verify_release(args) -> int:
    """`verify-release <zip>`: check a downloaded release's signature against the
    pinned Keystone key — the same check `update --from-zip` runs, exposed so an
    owner can vet a download by hand before installing it."""
    from . import signing
    zp = Path(args.zip).expanduser()
    if not zp.is_file():
        print(f"error: {zp} does not exist")
        return 1
    sig = Path(args.sig).expanduser() if args.sig else zp.with_name(zp.name + signing.SIG_SUFFIX)
    if not signing.is_configured():
        print("note: release signing is not active in this build (no key pinned) — "
              "nothing to verify against.")
        return 0
    try:
        signing.verify_file(zp, sig)
    except signing.SignatureError as e:
        print(f"NOT VERIFIED: {e}")
        return 1
    print(f"VERIFIED — {zp.name} is authentically signed by Keystone "
          f"(key {signing.key_id(signing.RELEASE_PUBKEY_HEX)}).")
    return 0


def cmd_doctor(args) -> int:
    if getattr(args, "quiet", False):
        # Update-pipeline mode: a health GATE, not a report. Success is one line
        # (plus any WARN/ERROR verbatim); failure prints the full output.
        import io
        from contextlib import redirect_stdout
        buf = io.StringIO()
        with redirect_stdout(buf):
            rc = _cmd_doctor_body(args)
        out = buf.getvalue()
        if rc != 0:
            print(out, end="")
            return rc
        kept = [l for l in out.splitlines() if "WARN" in l or "ERROR" in l]
        for l in kept:
            print(l)
        print("doctor: OK — no errors.")
        return rc
    return _cmd_doctor_body(args)


def _cmd_doctor_body(args) -> int:
    from . import config as cfgmod, modules as modmod, manifest
    import os
    vault = Path(args.vault).expanduser()
    config = cfgmod.load_config(str(vault))
    if not config:
        print(f"No config at {cfgmod.config_path(str(vault))} — is this an Alexandria vault?")
        return 1
    print("Config:")
    errs = _print_problems(cfgmod.validate(config)) or print("  OK") or 0
    print("Modules:")
    ids, modprob = modmod.resolve(cfgmod.enabled_modules(config))
    errs += _print_problems(modprob) or (print("  " + ", ".join(ids)) or 0)
    print("Secrets (names only; values live off-repo / in CI):")
    backend = (config.get("secrets") or {}).get("backend", "dotenv")
    for name in (config.get("secrets") or {}).get("required", []):
        if backend == "github-actions":
            print(f"  {name}: set as a GitHub Actions secret (can't verify locally)")
        else:
            print(f"  {name}: {'present in env' if os.environ.get(name) else 'NOT in env'}")
    runner = (config.get("automation") or {}).get("runner")
    if runner == "laptop-github-actions" and {"team-digests", "principal-inbox"} & set(ids):
        print("Note: on the laptop runner, inbound email (team/principal) is "
              "polled once per cron tick, not real-time.")
    if runner == "server-macos":
        print("Server (server-macos):")
        for line in _doctor_server_macos(vault):
            print("  " + line)
    if not (vault / "Agent" / "skills").is_dir():
        print("WARN: Agent/skills/ is missing — the playbooks won't be available to any "
              "client, over MCP or at a checkout. Re-run init/update.")
    elif not (vault / ".claude" / "skills").is_dir():
        print("WARN: .claude/skills/ is missing — the playbooks are present in "
              "Agent/skills/ but Claude Code won't auto-load them. Re-run init/update; "
              "if it keeps vanishing, a global gitignore is excluding .claude/ (the "
              "vault .gitignore force-includes it, so check `git status`).")
    drift = manifest.drift(str(vault))
    if drift:
        print(f"Core drift: {len(drift)} hand-edited Core file(s) — your changes belong in Config:")
        for p in drift:
            print("  " + p)
    errs += _doctor_federation(config)
    print(f"\n{'OK — no errors.' if not errs else str(errs) + ' error(s).'}")
    return 1 if errs else 0


def _doctor_federation(config):
    """Federation health (§6): identity key present + 0600, mailbox registered, and a
    stored deposit token per v2 peer. Returns the error count. Silent when the vault
    isn't set up for federation (no block, no v2 peers)."""
    fed = config.get("federation")
    v2 = [p for p in (config.get("peers") or []) if p.get("pubkey") and p.get("fingerprint")]
    if not fed and not v2:
        return 0
    print("Federation:")
    # Crypto peers with no persisted relay_url = the silent-inbound gap: the served
    # worker reads config FROM DISK and won't start without federation.relay_url, so
    # a vault paired before the block materialized never receives. A WARN, not an
    # error — the vault is otherwise healthy, and any federation command fixes it.
    if v2 and not (fed.get("relay_url") if isinstance(fed, dict) else None):
        print("  WARN: peers are configured but federation.relay_url isn't persisted "
              "in Agent/config.json — the served worker won't start, so inbound "
              "federation is never received; run any `alexandria peer`/`federation` "
              "command (or add the block) to activate it.")
    try:
        from alexandria_vault.federation import identity, state as fed_state
    except Exception as e:  # noqa: BLE001
        print(f"  WARN: the `federation` extra isn't installed ({e}).")
        return 0
    import os
    import stat
    errs = 0
    home = identity.federation_home(create=False)
    key = home / identity.KEY_FILE
    if not key.exists():
        print("  no identity key yet (created at birth — the next served boot or "
              "`federation tick` — or on first pairing).")
    else:
        mode = stat.S_IMODE(key.stat().st_mode)
        if mode != 0o600:
            print(f"  ERROR: {key} is mode {oct(mode)} — must be 0600.")
            errs += 1
        else:
            print("  identity key present (0600).")
    url = (fed or {}).get("relay_url")
    mb = fed_state.mailbox_for(url) if url else {}
    print(f"  mailbox: {'registered' if mb.get('bearer') else 'not registered (registered at birth or on first pairing)'}")
    # The census line (docs/RELAY-CENSUS.md §4.1): did THIS identity announce its
    # birth? birth.json is per relay, so a rotated key's record must not count.
    birth = fed_state.birth_for(url) if url else {}
    _, pub = identity.try_load()
    ours = identity.fingerprint(pub) if pub is not None else None
    if (fed or {}).get("announce") is False:
        print("  birth: not announced — federation.announce is false.")
    elif not url:
        print("  birth: nothing to announce to (no relay_url).")
    elif birth.get("announced_at") and ours and birth.get("fingerprint") == ours:
        import time as _time
        when = _time.strftime("%Y-%m-%d", _time.gmtime(float(birth["announced_at"])))
        print(f"  announced to {url} on {when} (fingerprint {ours[:12]}…, "
              f"engine {birth.get('engine')}).")
    else:
        why = (f" (last attempt: {birth['error']})"
               if birth.get("error") and birth.get("fingerprint") == ours else "")
        print(f"  not yet announced — served worker will retry{why}.")
    # A declared crypto peer without a deposit token is *pending pairing*, not an
    # error: you know their pubkey but haven't completed the handshake that hands
    # you a token for their mailbox. Only peers you intend to push to (can_send)
    # are worth flagging, and only as a status line — the push path itself refuses
    # cleanly when a token is missing. doctor's error tier stays for real breakage.
    ready = 0
    pending = []
    for p in v2:
        if fed_state.peer_secret_get(p["fingerprint"]):
            ready += 1
        elif p.get("can_send"):
            pending.append(p.get("handle") or p["fingerprint"][:12])
    if ready:
        print(f"  {ready} peer(s) paired for sending.")
    for h in pending:
        print(f"  @{h}: declared, not yet paired to send (run `alexandria peer invite`).")
    return errs


def _read_env_file(path: str) -> dict:
    """Parse an existing KEY=VALUE env file into a dict (blank/comment lines ignored)."""
    out = {}
    try:
        with open(path, "r", encoding="utf-8") as fh:
            for line in fh:
                s = line.strip()
                if not s or s.startswith("#") or "=" not in s:
                    continue
                k, v = s.split("=", 1)
                out[k.strip()] = v.strip()
    except OSError:
        pass
    return out


def _write_env_file(path: str, values: dict) -> None:
    """Atomically write KEY=VALUE lines to `path`, chmod 600 (parent dir 700)."""
    import os, tempfile
    d = os.path.dirname(path) or "."
    os.makedirs(d, exist_ok=True)
    try:
        os.chmod(d, 0o700)
    except OSError:
        pass
    body = ["# Alexandria secrets — written by `alexandria secrets --set`.",
            "# Off-repo, chmod 600, never committed. One KEY=VALUE per line.", ""]
    body += [f"{k}={v}" for k, v in values.items() if v]
    fd, tmp = tempfile.mkstemp(dir=d, prefix=".env-")
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as fh:
            fh.write("\n".join(body) + "\n")
        os.chmod(tmp, 0o600)
        os.replace(tmp, path)
    except BaseException:
        try:
            os.unlink(tmp)
        except OSError:
            pass
        raise


# IMAP (receiving) normally uses the SAME Gmail mailbox as SMTP (sending) — one App Password
# works for both — so the setters reuse the SMTP values instead of asking twice. A pre-set
# IMAP_* value (an advanced separate mailbox) always wins.
_IMAP_FROM_SMTP = {"IMAP_USER": "SMTP_USER", "IMAP_PASS": "SMTP_PASS", "IMAP_HOST": "SMTP_HOST"}


def _smtp_to_imap_host(host: str) -> str:
    """smtp.gmail.com → imap.gmail.com (mirror the sending host for receiving)."""
    import re
    return re.sub(r"(^|\.)smtp\.", r"\1imap.", host, count=1) if host else host


def _reused_imap_value(name: str, values: dict):
    """The value an IMAP_* secret should inherit from the already-collected SMTP_* (same
    mailbox), or None if there's nothing to reuse."""
    src = _IMAP_FROM_SMTP.get(name)
    if not src or not values.get(src):
        return None
    return _smtp_to_imap_host(values[src]) if name == "IMAP_HOST" else values[src]


def _mailbox_from_config(config: dict) -> str:
    """The real SMTP/IMAP login mailbox implied by config — the base of the agent's ``+alias``
    email (``pnb001+alexandria@…`` → ``pnb001@…``), else the owner's first delivery address.
    The plaintext default for SMTP_USER / IMAP_USER, so the operator never retypes a known value."""
    email = ((config.get("agent") or {}).get("email") or "").strip()
    if "@" in email:
        local, domain = email.split("@", 1)
        return f"{local.split('+', 1)[0]}@{domain}"
    emails = (config.get("owner") or {}).get("emails") or []
    return emails[0] if emails else ""


def _is_hidden_secret(name: str) -> bool:
    """Passwords/keys/tokens are entered hidden; hostnames + usernames (``*_HOST``/``*_USER``/
    ``*_PORT``) are config, not secrets — shown as plaintext with a default you accept with Enter."""
    return not name.endswith(("_HOST", "_USER", "_PORT"))


def _secret_default(name: str, config: dict) -> str:
    """A pre-fillable default for a config-like (non-hidden) secret: the Gmail hosts + the mailbox."""
    if name == "SMTP_HOST":
        return "smtp.gmail.com"
    if name == "IMAP_HOST":
        return "imap.gmail.com"
    if name in ("SMTP_USER", "IMAP_USER"):
        return _mailbox_from_config(config)
    return ""


def _is_passphrase_secret(name: str) -> bool:
    """`*_PASSPHRASE_SHA256` stores the HASH of a passphrase the owner includes in cold emails
    (a second auth factor on top of DKIM/SPF). The setter prompts for the WORD and hashes it —
    the plaintext is never stored, and the owner never computes a hash by hand."""
    return name.endswith("_PASSPHRASE_SHA256")


def _prompt_passphrase(name: str, allow_keep: bool, getpass):
    """Prompt (hidden, entered twice) for an email passphrase and return its SHA-256 hex —
    matching the inbox gate exactly (a single alphanumeric token, lower-cased). Returns None to
    skip / keep the current value."""
    import hashlib
    import re
    print(f"  {name} — the passphrase you'll include in cold/new emails to the vault, so it can")
    print("    double-check it's really you (one word or code: letters + digits, no spaces).")
    prompt = "    passphrase [Enter = keep current]: " if allow_keep else "    passphrase: "
    word = getpass(prompt).strip()
    if not word:
        return None
    if not re.fullmatch(r"[A-Za-z0-9]+", word):
        print("    · a passphrase must be one word/code (letters + digits, no spaces) — skipped")
        return None
    if getpass("    confirm: ").strip() != word:
        print("    · the two entries didn't match — skipped")
        return None
    return hashlib.sha256(word.lower().encode()).hexdigest()


def _secrets_set(config: dict, walk) -> int:
    """Interactively enter secret VALUES (hidden) and store them — no editor.
    dotenv backend → the off-repo env file; github-actions → `gh secret set`."""
    import os, sys
    from getpass import getpass
    if not sys.stdin.isatty():
        print("`alexandria secrets --set` needs an interactive terminal — each value is typed "
              "hidden. Open Terminal and run it there.")
        return 1
    names = walk.required_secrets(config)
    if not names:
        print("No secrets required for this vault.")
        return 0
    if walk.backend(config) == "github-actions":
        return _secrets_set_gh(config, names)
    path = os.path.expanduser(walk.DEFAULT_ENV_PATH)
    existing = _read_env_file(path)
    print(f"Entering secret values → {path}")
    print("Passwords + keys are entered hidden; hostnames + usernames show a default you accept")
    print("with Enter. Press Enter to keep an existing value / skip one.\n")
    values = dict(existing)
    # Collect SMTP (and everything else) first, then let IMAP reuse the same Gmail mailbox
    # so the App Password is never entered twice.
    ordered = [n for n in names if not n.startswith("IMAP_")] + [n for n in names if n.startswith("IMAP_")]
    for name in ordered:
        if not values.get(name):
            reused = _reused_imap_value(name, values)
            if reused is not None:
                values[name] = reused
                extra = (" → imap host" if name == "IMAP_HOST"
                         else " (same App Password as SMTP)" if name == "IMAP_PASS" else "")
                print(f"  {name}: reused from {_IMAP_FROM_SMTP[name]}{extra}")
                continue
        has = bool(existing.get(name))
        try:
            if _is_passphrase_secret(name):
                h = _prompt_passphrase(name, has, getpass)
                if h:
                    values[name] = h
                    print("    · stored (as a hash)")
                elif not has:
                    print("    · left unset")
                continue
            if _is_hidden_secret(name):
                v = getpass(f"  {name} [{'keep current' if has else 'required'}]: ")
            else:
                # config, not a secret: plaintext, pre-filled with a default you accept via Enter.
                default = existing.get(name) or _secret_default(name, config)
                v = input(f"  {name} [{default or 'required'}]: ").strip() or default
        except (EOFError, KeyboardInterrupt):
            print("\nAborted — nothing written.")
            return 1
        if v:
            values[name] = v
        elif not has:
            print("    · left unset")
    _write_env_file(path, values)
    have = [n for n in names if values.get(n)]
    print(f"\nWrote {len(have)}/{len(names)} secret(s) to {path}  (chmod 600, dir 700).")
    print("Hidden under ~/Library, outside your vault — never committed.")
    missing = [n for n in names if not values.get(n)]
    if missing:
        print("Still unset: " + ", ".join(missing) + " — re-run `alexandria secrets --set` to add them.")
    return 0


def _secrets_set_gh(config: dict, names: list) -> int:
    """github-actions backend: read each value hidden, push via `gh secret set` (value on stdin)."""
    import subprocess
    from getpass import getpass
    repo = config.get("repo") or input("GitHub repo (owner/name): ").strip()
    if not repo:
        print("No repo given — aborting.")
        return 1
    ok = 0
    collected = {}
    ordered = [n for n in names if not n.startswith("IMAP_")] + [n for n in names if n.startswith("IMAP_")]
    for name in ordered:
        reused = _reused_imap_value(name, collected)
        if reused is not None:
            v = reused
            print(f"    {name}: reusing {_IMAP_FROM_SMTP[name]} (same mailbox)")
        else:
            try:
                if _is_passphrase_secret(name):
                    v = _prompt_passphrase(name, False, getpass)
                else:
                    v = getpass(f"  {name}: ")
            except (EOFError, KeyboardInterrupt):
                print("\nAborted.")
                return 1
            if not v:
                print(f"    · skipped {name}")
                continue
        collected[name] = v
        r = subprocess.run(["gh", "secret", "set", name, "--repo", repo],
                           input=v, text=True, capture_output=True)
        if r.returncode == 0:
            ok += 1
            print(f"    set {name}")
        else:
            print(f"    FAILED {name}: {(r.stderr or '').strip() or 'gh error'}")
    print(f"\nSet {ok}/{len(names)} secret(s) on {repo}.")
    return 0


def cmd_secrets(args) -> int:
    from . import config as cfgmod, secrets_walkthrough as walk
    vault = Path(args.vault).expanduser()
    config = cfgmod.load_config(str(vault))
    if not config:
        print(f"No config at {cfgmod.config_path(str(vault))} — is this an Alexandria vault?")
        return 1
    if getattr(args, "set", False):
        return _secrets_set(config, walk)
    print(walk.render_text(config))
    return 0


def cmd_status(args) -> int:
    from . import manifest
    vault = Path(args.vault).expanduser()
    lock = manifest.state_dir(vault) / manifest.RENDER_LOCK
    if not lock.exists():
        print(f"No .state/render.lock at {vault} — not an Alexandria vault (or never initialized).")
        return 1
    info = json.loads(lock.read_text(encoding="utf-8"))
    man = manifest.load_manifest(str(vault))
    files = man.get("files", [])
    by_bucket = {}
    for e in files:
        by_bucket[e["bucket"]] = by_bucket.get(e["bucket"], 0) + 1
    print(f"Alexandria vault: {vault}")
    print(f"  version:      {info.get('version')}")
    print(f"  template_ref: {info.get('template_ref')}")
    commit = info.get("source_commit_short")
    if commit:
        ref = info.get("source_ref")
        print(f"  commit:       {commit}" + (f" ({ref})" if ref else "")
              + (" · dirty" if info.get("source_dirty") else ""))
    if info.get("source_repo"):
        print(f"  source:       {info.get('source_repo')}")
    print(f"  files:        {len(files)} (" + ", ".join(f"{k}={v}" for k, v in sorted(by_bucket.items())) + ")")
    d = manifest.drift(str(vault))
    print(f"  core drift:   {len(d)} hand-edited Core file(s)" + (": " + ", ".join(d) if d else ""))
    return 0


def _verify_source_signature(src, args):
    """Authenticity preflight for a `--from-zip` source. Returns
    ``(error_or_'', authority)``: ``authority`` is ``'release'`` / ``'beta'`` on a verified
    install (F16 — a beta build is signed by the pinned beta key, NOT unsigned) or ``''``
    when verification was skipped (dormant, or --allow-unsigned). A non-empty error refuses.

    Enforced only once a real release key is pinned (`signing.is_configured()`); until then
    the mechanism is dormant. `--allow-unsigned` is the explicit, loud escape hatch."""
    from . import signing
    src = Path(src)
    if not signing.is_configured():
        return "", ""                    # signing not yet activated for this build
    if getattr(args, "allow_unsigned", False):
        print("  WARNING: --allow-unsigned — installing WITHOUT verifying the "
              "signature. Only do this for a build you trust by other means.")
        return "", ""
    if src.is_dir():
        return ("cannot verify the signature of an unzipped folder — point "
                "--from-zip at the .zip itself (its .sig is published beside it), "
                "or pass --allow-unsigned to install without verification.", "")
    sig = src.with_name(src.name + signing.SIG_SUFFIX)
    # Beta builds are trusted ONLY on a brain that opted into the beta channel (F16 opt-in):
    # `alexandria.beta_channel: true` in Agent/config.json. Production brains never trust it.
    allow_beta = False
    try:
        _cfg = json.loads((Path(getattr(args, "vault", ".") or ".") / "Agent" / "config.json")
                          .read_text(encoding="utf-8"))
        allow_beta = bool((_cfg.get("alexandria") or {}).get("beta_channel"))
    except Exception:  # noqa: BLE001 — no/malformed config ⇒ release-only, the safe default
        allow_beta = False
    try:
        authority = signing.verify_any(src, sig, allow_beta=allow_beta)   # release, or beta if opted in
    except signing.SignatureError as e:
        return str(e), ""
    kind = "Beta" if authority == "beta" else "Release"
    note = "  (a BETA build — signed by the beta key, not the release key)" \
        if authority == "beta" else ""
    print(f"  {kind} signature verified ({src.name}).{note}")
    return "", authority


AUTO_UPDATE_URL = "https://keystone.computer/Alexandria.zip"
# The tiny version endpoint (~60 bytes, emitted by build-zip.sh, deployed beside
# the zip): a SEMANTIC "what's current" answer, so the daily check usually costs
# one tiny fetch and zero downloads. Trigger-only — authority stays with the
# zip's Ed25519 signature, which is always verified before anything installs.
AUTO_UPDATE_VERSION_URL = "https://keystone.computer/Alexandria.version.json"
AUTO_CHECK_INTERVAL_HOURS = 24
# Under .alexandria/ (gitignored), NOT .state/ (tracked) — the rate-limit stamp
# must never dirty the owner's vault tree, or it would trip the clean-tree gate
# it just wrote past and defer every auto-update.
_AUTO_CHECK_STATE = ".alexandria/update-check.json"


def _auto_update_enabled(cfg) -> bool:
    """Absent means ON — brains stay current on their own unless the owner turns
    `alexandria.auto_update` off."""
    return bool((cfg.get("alexandria") or {}).get("auto_update", True))


def _download(url: str, dest, max_bytes: int = 256 * 1024 * 1024) -> None:
    """Fetch ``url`` to ``dest`` — https only (including after redirects), and
    capped: a hostile or broken channel must not be able to balloon a session's
    memory or disk with a multi-GB body. The cap is generous for the artifact
    each call expects (the zip is ~1 MB; sidecars are bytes)."""
    import urllib.request
    if not url.startswith("https://"):
        raise ValueError("refusing a non-https update URL")
    req = urllib.request.Request(url, headers={"User-Agent": "alexandria-update"})
    with urllib.request.urlopen(req, timeout=60) as r, open(dest, "wb") as f:
        final = r.geturl()
        if not str(final).startswith("https://"):
            raise ValueError(f"refusing a redirect off https ({final})")
        got = 0
        while True:
            chunk = r.read(1024 * 1024)
            if not chunk:
                break
            got += len(chunk)
            if got > max_bytes:
                raise ValueError(f"{url} exceeded the {max_bytes}-byte download cap")
            f.write(chunk)


def _cmd_update_auto(args) -> int:
    """`update --auto`: keep the brain current on its own. Rate-limited to a
    daily check; fetches the signed release from keystone.computer, VERIFIES its
    signature, and applies it only if it is genuinely newer AND the vault tree is
    clean — a signed, authenticated, survivable, reviewable update, or nothing.
    Silent when there's nothing to do (checked recently / already current), so it
    is safe to run at the start of every session. Never installs an unsigned or
    unverifiable release; never touches uncommitted owner work."""
    import json
    import time
    from . import zipupdate, signing, config as cfgmod
    vault = Path(args.vault).expanduser().resolve()
    # Auto-update is a RECIPIENT feature: only a zip-installed folder brain — one
    # whose software lives in .alexandria/app/ — is ever pulled current from the
    # release channel. A SOURCE vault (the reference brain the distribution is
    # built from) or a dev checkout keeps its software as an external git
    # checkout and is updated deliberately, by hand. Such a vault is never an
    # auto-update target, by construction — it stays a source, not a recipient.
    if not (vault / zipupdate.APP_SUBDIR).is_dir():
        return 0
    cfg = cfgmod.load_config(str(vault)) or {}
    if not _auto_update_enabled(cfg):
        return 0                                     # owner opted out — silent
    if not signing.is_configured():
        return 0                                     # no pinned key ⇒ can't auto-trust
    state = vault / _AUTO_CHECK_STATE
    now = time.time()
    if not getattr(args, "force", False):
        try:
            last = float(json.loads(state.read_text(encoding="utf-8"))["ts"])
            if now - last < AUTO_CHECK_INTERVAL_HOURS * 3600:
                return 0                             # checked recently — silent
        except (OSError, ValueError, KeyError, TypeError):
            pass

    tmp = vault / ".alexandria" / "tmp"
    tmp.mkdir(parents=True, exist_ok=True)
    zp, sig = tmp / "auto-Alexandria.zip", tmp / "auto-Alexandria.zip.sig"

    def _stamp(sha=None):
        payload = {"ts": now}
        if sha:
            payload["sha"] = sha
        try:
            state.parent.mkdir(parents=True, exist_ok=True)
            state.write_text(json.dumps(payload) + "\n", encoding="utf-8")
        except OSError:
            pass

    def _cleanup():
        for p in (zp, sig):
            try:
                p.unlink()
            except OSError:
                pass

    # Cheapest first: the version endpoint — a semantic answer ("what's
    # current?"), so being up to date costs ~60 bytes and no comparison
    # gymnastics. Endpoint says NEWER → we also know enough to defer on a dirty
    # tree BEFORE any big download. Endpoint missing (older site deploy) → fall
    # back to the .sha256 change probe, then the full zip. The endpoint is
    # trigger-only: versions are re-read from the zip itself and nothing
    # installs without the signature verifying.
    try:
        installed_now = zipupdate.installed_app_version(vault)
    except zipupdate.ZipUpdateError:
        installed_now = None
    endpoint, probe = None, None
    try:
        vf = tmp / "auto-Alexandria.version.json"
        _download(AUTO_UPDATE_VERSION_URL, vf, max_bytes=64 * 1024)
        endpoint = json.loads(vf.read_text(encoding="utf-8"))
        vf.unlink()
    except Exception:                                # noqa: BLE001 - probe is best-effort
        endpoint = None
    if isinstance(endpoint, dict) and endpoint.get("version") and installed_now:
        probe = (str(endpoint.get("sha256") or "")).strip().lower() or None
        et = zipupdate.version_tuple(endpoint["version"])
        it = zipupdate.version_tuple(installed_now)
        installed_auth = zipupdate.installed_build_authority(vault)
        # Stop early (current) when the channel is OLDER, or SAME-version and we're already
        # on a release build. A same-version RELEASE on the channel while we're on a
        # beta/unsigned build means CONVERGE (F15) — fall through to download + verify + swap.
        if et < it or (et == it and installed_auth == "release"):
            _stamp(sha=probe)
            return 0                                 # current — one tiny fetch, silent
        gate = zipupdate.require_git_rollback_surface(vault)
        if gate:
            print(f"  A signed Alexandria {endpoint['version']} is available — commit "
                  f"your work, then say \"update my brain\" (not auto-applied: "
                  f"{gate.split(' — ')[0]}).", flush=True)
            _stamp()                                 # no sha: keep re-surfacing daily
            return 0
    elif endpoint is None:
        # Older site deploy without the endpoint: the ~100-byte .sha256 change probe.
        try:
            pf = tmp / "auto-Alexandria.zip.sha256"
            _download(AUTO_UPDATE_URL + ".sha256", pf, max_bytes=64 * 1024)
            probe = pf.read_text(encoding="utf-8").split()[0].strip().lower() or None
            pf.unlink()
        except Exception:                            # noqa: BLE001 - probe is best-effort
            probe = None
        if probe:
            try:
                seen = json.loads(state.read_text(encoding="utf-8")).get("sha")
            except (OSError, ValueError, AttributeError):
                seen = None
            # Unchanged channel ⇒ current — but only if we're already on a release build;
            # a beta/unsigned brain must still fetch to converge (F15), mirroring the
            # endpoint gate above rather than short-circuiting purely on the sha.
            if (seen and probe == seen
                    and zipupdate.installed_build_authority(vault) == "release"):
                _stamp(sha=probe)
                return 0                             # channel unchanged — silent, no zip fetch
    try:
        _download(AUTO_UPDATE_URL, zp)
        _download(AUTO_UPDATE_URL + ".sig", sig, max_bytes=64 * 1024)
    except Exception:                                # noqa: BLE001 - network is best-effort
        _stamp()
        return 0                                     # offline ⇒ try again next session, silent
    _stamp()

    try:
        signing.verify_file(zp, sig)                 # NEVER auto-apply the unverified
    except signing.SignatureError as e:
        print(f"  ⚠️ auto-update: the latest release DID NOT VERIFY — not applying ({e}). "
              "Relay this to the owner: it means the download was corrupted or the channel "
              "is serving something not signed by Keystone.", flush=True)
        _cleanup()
        return 0
    try:
        incoming = zipupdate.read_source_version(zp)
        installed = zipupdate.installed_app_version(vault)
    except zipupdate.ZipUpdateError:
        _cleanup()
        return 0
    inc_t = zipupdate.version_tuple(incoming)
    ins_t = zipupdate.version_tuple(installed)
    installed_auth = zipupdate.installed_build_authority(vault)
    converging = inc_t == ins_t and installed_auth != "release"     # F15
    if inc_t < ins_t or (inc_t == ins_t and not converging):
        _stamp(sha=probe)                            # remember: this channel state is current
        _cleanup()
        return 0                                     # already current — silent

    gate = zipupdate.require_git_rollback_surface(vault)
    if gate:
        # A signed newer release exists but the tree isn't clean — never sweep
        # the owner's uncommitted notes into an auto-update; surface it and defer.
        print(f"  A signed Alexandria {incoming} is available — commit your work, then say "
              f"\"update my brain\" (not auto-applied: {gate.split(' — ')[0]}).", flush=True)
        _cleanup()
        return 0

    lock = vault / ".alexandria" / "update.lock"
    if lock.is_dir():
        import time as _t
        try:
            fresh = (_t.time() - lock.stat().st_mtime) < 1800
        except OSError:
            fresh = True
        if fresh:
            # Another session of this same brain is mid-update. Its outcome
            # covers us; a lock older than 30 min falls through to the normal
            # path, whose stale-break logic owns that case.
            print("  (another session is already updating this brain — letting it finish)",
                  flush=True)
            _cleanup()
            return 0
    if converging:
        print(f"Converging to the signed Alexandria {incoming}: you're on a "
              f"{installed_auth} build of the same version — verifying and applying it.",
              flush=True)
    else:
        print(f"Keeping current: a signed Alexandria {incoming} is available (you have "
              f"{installed}) — verifying and applying it.", flush=True)
    synth = argparse.Namespace(vault=str(vault), from_zip=str(zp), no_pull=False,
                               safe=False, rollback=False, allow_unsigned=False,
                               reinstall=False, stamp=None, auto=True)
    rc = _cmd_update_from_zip(synth)
    if rc != 0:
        _cleanup()
        return rc
    # Auto-updates commit themselves — the pre-update tree was clean (gate above),
    # so this captures the signed update's output. (Known micro-window: a file the
    # owner writes during the few seconds of apply rides along in this commit —
    # mislabeled, never lost; there is no clean way to tell it apart from render
    # output.) Author = the vault's own configured identity (set by init /
    # personalization); the synthetic fallback only covers a repo with none.
    import subprocess
    subprocess.run(["git", "-C", str(vault), "add", "-A"], capture_output=True)
    msg = f"alexandria: auto-update {installed} → {incoming} (signed release)"
    r = subprocess.run(["git", "-C", str(vault), "commit", "-qm", msg],
                       capture_output=True)
    if r.returncode != 0:
        subprocess.run(["git", "-C", str(vault), "-c", "user.name=alexandria-brain",
                        "-c", "user.email=brain@localhost", "commit", "-qm", msg],
                       capture_output=True)
    _stamp(sha=probe)                                # applied: this channel state is on board
    _cleanup()
    print(f"Updated Alexandria {installed} → {incoming} on its own — signed, verified, committed.")
    return 0


def _cmd_update_from_zip(args) -> int:
    """`update --from-zip`: advance a folder-brain's software from an Alexandria.zip
    (or an unzipped copy of one), then re-render Core through the NEW code. The
    swap is survivable by construction — verify first, swap aside (never delete),
    roll back on any failure before the render, keep `.alexandria/app.prev` on any
    failure after it. See scripts/zipupdate.py. Committing stays with the caller."""
    from . import zipupdate
    # Resolved, not just expanded: the re-exec runs with cwd inside the app dir,
    # so a relative --vault (the usual `.`) would dangle every path derived here.
    vault = Path(args.vault).expanduser().resolve()
    src = Path(args.from_zip).expanduser()
    try:
        incoming = zipupdate.read_source_version(src)
    except zipupdate.ZipUpdateError as e:
        print(f"error: {e}")
        return 1
    gate = zipupdate.require_git_rollback_surface(vault)
    if gate:
        print(f"error: {gate}")
        return 1
    sig_gate, incoming_authority = _verify_source_signature(src, args)
    if sig_gate:
        print(f"error: {sig_gate}")
        return 1
    try:
        with zipupdate.update_lock(vault):
            # State reads happen INSIDE the lock: another run may have healed,
            # updated, or rolled back between our check and our action —
            # a pre-lock version compare is a check-then-act downgrade waiting
            # to happen.
            healed = zipupdate.heal_interrupted(vault)
            if healed:
                print(f"  ({healed})")
            installed = zipupdate.installed_app_version(vault)
            inst_t = zipupdate.version_tuple(installed)
            inc_t = zipupdate.version_tuple(incoming)
            app = vault / zipupdate.APP_SUBDIR
            app_ok = zipupdate._app_viable(app)
            if inc_t < inst_t and app_ok:
                # A healthy brain won't step backward silently; a BROKEN one may
                # (falls through to reinstall below — the escape from a
                # crash-then-only-an-older-zip-on-hand trap).
                print(f"error: {src.name} carries v{incoming}, older than the installed "
                      f"v{installed} — refusing to downgrade (to revert an update: "
                      "`alexandria update --rollback`). Fetch the current release: "
                      "https://keystone.computer/Alexandria.zip")
                return 1
            if inc_t == inst_t and app_ok:
                # Same version. Normally no swap — just re-render in place. But swap when
                # asked (`--reinstall`) or to CONVERGE a non-release build to the signed
                # release (F15): a brain on a beta/unsigned build adopts the official bytes
                # the moment they arrive, instead of being stranded on test code forever.
                installed_auth = zipupdate.installed_build_authority(vault)
                converge = incoming_authority == "release" and installed_auth != "release"
                if not (getattr(args, "reinstall", False) or converge):
                    print(f"Already on v{installed} — no software swap; re-rendering "
                          "Core, running doctor, refreshing channel files.")
                    zipupdate.repair_in_place(vault, src)
                    if not getattr(args, "auto", False):
                        print("Commit the changes if any (the update never commits for you).")
                    return 0
                reason = ("--reinstall" if getattr(args, "reinstall", False)
                          else f"converging from a {installed_auth} build to the signed release")
                print(f"Re-installing v{incoming} — {reason}…", flush=True)
            elif not app_ok:
                # No runnable environment (an interrupted update, nothing left to
                # heal from): (re)install whatever the source carries — a broken
                # brain takes any valid release, forward or back.
                print(f"The installed software is not runnable — (re)installing "
                      f"v{incoming} from {src.name}.")
            else:
                print(f"Updating software v{installed} → v{incoming} from {src.name}…",
                      flush=True)
            res = zipupdate.apply(vault, src, authority=incoming_authority)
    except zipupdate.ZipUpdateError as e:
        print(f"error: {e}")
        return 1
    except Exception as e:                                    # noqa: BLE001
        app = vault / zipupdate.APP_SUBDIR
        state = ("present (venv ok)" if zipupdate._app_viable(app)
                 else "present (no venv)" if app.exists() else "MISSING")
        print(f"error: unexpected failure: {e!r}")
        print(f"  On disk now: app {state} · app.swap "
              f"{'present' if app.with_name('app.swap').exists() else 'absent'} · "
              f"app.prev {'present' if app.with_name('app.prev').exists() else 'absent'}."
              " The vault's git history has every rendered file's prior state; "
              "re-running the update self-heals an interrupted swap.")
        return 1
    landed = res.get("landed", incoming)
    # (authority is stamped inside zipupdate.apply, right after the swap, so a failed
    #  post-swap render can't leave the build mislabeled — F15.)
    print(f"Software updated to v{landed}"
          + (f" · refreshed from the zip: {', '.join(res['refreshed'])}" if res.get("refreshed") else ""))
    if res.get("prev_kept"):
        print("  The previous software is kept at .alexandria/app.prev — "
              "`alexandria update --rollback` reverts until the next update replaces it.")
    if getattr(args, "auto", False):
        print("  What changed is in .state/last-update.md.")
    else:
        print("Commit the changes (the update never commits for you); "
              "what changed is in .state/last-update.md.")
    return 0


def _cmd_update_rollback(args) -> int:
    """`update --rollback`: swap the kept previous software back into place and
    re-render Core with it. The version gate deliberately does not apply."""
    from . import zipupdate
    vault = Path(args.vault).expanduser().resolve()
    gate = zipupdate.require_git_rollback_surface(vault)
    if gate:
        print(f"error: {gate}")
        return 1
    try:
        with zipupdate.update_lock(vault):
            # Reconcile any interrupted state first, then roll back from a known
            # layout. If the heal already consumed the only retained copy,
            # rollback() reports "nothing to roll back to" honestly.
            healed = zipupdate.heal_interrupted(vault)
            if healed:
                print(f"  ({healed})")
            res = zipupdate.rollback(vault)
    except zipupdate.ZipUpdateError as e:
        print(f"error: {e}")
        return 1
    except Exception as e:                                    # noqa: BLE001
        print(f"error: unexpected failure during rollback: {e!r} — nothing is "
              "deleted by a rollback; check .alexandria/ (app / app.prev / "
              "app.next) and the vault's git status.")
        return 1
    print("Software rolled back; Core re-rendered and doctor is green.")
    # If we rolled back TO a non-release build, --auto would re-converge it to the
    # same-version signed release within a day (F15), silently undoing this rollback.
    # Say so, so the owner isn't surprised (and knows to pause auto-update to stay put).
    if zipupdate.installed_build_authority(vault) != "release":
        print("  NOTE: this is a beta/unsigned build — `update --auto` will re-converge it "
              "to the signed release of the same version on its next run. To stay on this "
              "build, set alexandria.auto_update: false until you're ready.")
    if res.get("newer_kept"):
        print("  The newer copy is set aside at .alexandria/app.next (undo the "
              "rollback by moving it back to .alexandria/app; the next successful "
              "update removes it).")
    print("  If the rolled-back version predates `--from-zip`, re-update later by "
          "following the README inside a freshly downloaded Alexandria.zip.")
    print("Commit the changes (the rollback never commits for you).")
    return 0


def cmd_update(args, report_out=None) -> int:
    # Mutually-exclusive modes — check BEFORE dispatching, so a conflicting pair
    # can't have one flag silently win (e.g. `--safe --rollback`).
    modes = [m for m, on in (("--safe", getattr(args, "safe", False)),
                             ("--rollback", getattr(args, "rollback", False)),
                             ("--auto", getattr(args, "auto", False)),
                             ("--from-zip", bool(getattr(args, "from_zip", None))))
             if on]
    if len(modes) > 1:
        print(f"error: {' and '.join(modes)} are different operations — pick one.")
        return 1
    if getattr(args, "safe", False):
        return _cmd_update_safe(args)
    if getattr(args, "rollback", False):
        return _cmd_update_rollback(args)
    if getattr(args, "auto", False):
        return _cmd_update_auto(args)
    if getattr(args, "from_zip", None):
        return _cmd_update_from_zip(args)
    # `update` is render-only: refresh Core from the software that is already
    # installed. Getting NEW software is an explicit channel — `--from-zip` on a
    # folder brain (the release zip), a new image on a hosted brain (`--safe` at
    # boot). The old default here — `git pull` a software checkout, with a
    # .pkg-kit conversion and a PAT-injected URL — retired with the runbook tier;
    # a dev checkout pulls itself before rendering. `--no-pull` is accepted for
    # compatibility (internal re-execs and older scripts pass it) and just
    # suppresses the channel note below.
    if not getattr(args, "no_pull", False)             and (Path(args.vault).expanduser() / ".alexandria" / "app").is_dir():
        print("Note: this is a zip-installed brain — its update channel is the release "
              "zip: fetch https://keystone.computer/Alexandria.zip and run "
              "`alexandria update --from-zip <zip> --vault .`. Re-rendering Core with "
              "the current software now.", flush=True)

    from . import config as cfgmod, modules as modmod, render, manifest
    vault = Path(args.vault).expanduser()
    _migrate_legacy_folder(vault)
    _advise_nested_zip_layout(vault)
    cfg = cfgmod.load_config(str(vault))
    if not cfg:
        print(f"No config at {cfgmod.config_path(str(vault))} — not an Alexandria vault.")
        return 1
    if _print_problems(cfgmod.validate(cfg)):
        print("\nFix the config errors above and retry.")
        return 1
    ids, modprob = modmod.resolve(cfgmod.enabled_modules(cfg))
    if _print_problems(modprob):
        return 1

    pre_drift = manifest.drift(str(vault))
    _prev = manifest.load_manifest(str(vault)).get("files", [])
    old = {e["path"]: e.get("sha256") for e in _prev}
    _old_bucket = {e["path"]: e.get("bucket") for e in _prev}
    lock_path = manifest.state_dir(vault) / manifest.RENDER_LOCK
    old_lock = {}
    if lock_path.exists():
        try:
            old_lock = json.loads(lock_path.read_text(encoding="utf-8"))
        except ValueError:
            pass

    module_dirs = [modmod.template_dir(i) for i in ids]
    build, minfo = _build_and_modules(ids)
    # Default buckets + protected: Core is rewritten, NEW Config/Content seeds are added,
    # existing Config/Content is preserved untouched.
    written = render.render_vault(vault, module_dirs, cfg, build=build, modules_info=minfo,
                                  already_seeded=set(old))
    new_entries = manifest.write_state(
        vault, written, cfg, (cfg.get("alexandria") or {}).get("template_ref"),
        stamp=args.stamp, build=build)

    # Retire Core files this version no longer ships. Without this the renderer
    # only ever ADDED: a skill that was renamed left both copies installed (the
    # `personalize`→`settings` rename put two conflicting skills in every vault
    # rendered before it), and the 21 engine scripts dropped in 2.30 are still
    # sitting in every pre-2.30 vault. Worse, a retired file also falls out of the
    # manifest, so `drift` and `status` stop being able to see it at all.
    # Core only — Config and Content are the owner's, and are never removed.
    now = {w["path"] for w in written}
    retired = sorted(p for p, _sha in old.items()
                     if p not in now
                     and _old_bucket.get(p) == "core"
                     and (vault / p).is_file())
    for rel in retired:
        try:
            (vault / rel).unlink()
        except OSError:
            retired = [r for r in retired if r != rel]      # report only real removals

    old_sv = old_lock.get("software_version") or "?"
    new_sv = build.get("software_version") or "?"
    version_line = (f"- Alexandria: v{old_sv} → v{new_sv}" if old_sv != new_sv
                    else f"- Alexandria: v{new_sv} (unchanged)")
    old_c = old_lock.get("source_commit_short")
    new_c = build.get("source_commit_short")
    commit_line = None                       # only when a real build commit is known
    if old_c or new_c:
        commit_line = (f"- Commit: {old_c or '?'} → {new_c or '?'}" if old_c != new_c
                       else f"- Commit: {new_c} (unchanged)")

    changed = [e["path"] for e in new_entries
               if e["bucket"] == "core" and old.get(e["path"]) != e["sha256"]]
    added = [w["path"] for w in written
             if w.get("written") and w["path"] not in old]

    report = ["# Alexandria update", "",
              version_line,
              *([commit_line] if commit_line else []),
              f"- Core files refreshed: {len([e for e in new_entries if e['bucket']=='core'])}",
              f"- Core files changed: {len(changed)}",
              f"- New seeds added: {len(added)}"]
    if changed:
        report += ["", "## Changed Core"] + [f"- {p}" for p in changed]
    if added:
        report += ["", "## Added"] + [f"- {p}" for p in added]
    if retired:
        report += ["", "## Retired Core (no longer shipped by this version)"]
        report += [f"- {p}" for p in retired]
    if pre_drift:
        report += ["", "## Hand-edited Core (overwritten — move such changes into Config)"]
        report += [f"- {p}" for p in pre_drift]
    (manifest.state_dir(vault) / manifest.LAST_UPDATE).write_text(
        "\n".join(report) + "\n", encoding="utf-8")

    print(f"Updated Core in {vault} (modules: {', '.join(ids)}).")
    print((f"  Alexandria: v{old_sv} → v{new_sv}" if old_sv != new_sv
           else f"  Alexandria: v{new_sv} (unchanged)")
          + (f" · commit {new_c}" if new_c else ""))
    print(f"  Core changed: {len(changed)} · new seeds: {len(added)} · "
          f"retired: {len(retired)} · Config/Content preserved.")
    if pre_drift:
        print(f"  WARNING: {len(pre_drift)} hand-edited Core file(s) were overwritten "
              "(your changes belong in Config): " + ", ".join(pre_drift))
    print(f"  Report: {manifest.state_dir(vault) / manifest.LAST_UPDATE}")
    # Hand back what actually happened. On a hosted brain nobody reads this
    # stdout, so the caller (`update --safe`) folds these into
    # `.state/update-status.json` — otherwise the fact that an owner's Core edit
    # was reverted, or that files were retired, existed only in container logs.
    if report_out is not None:
        report_out.update({"core_changed": len(changed), "seeds_added": len(added),
                           "core_retired": retired,
                           "core_hand_edits_overwritten": pre_drift})
    _reload_launchd_after_update(vault, cfg)
    _maybe_vendor_engine_wheel(vault, ids)
    return 0


def _cmd_update_safe(args) -> int:
    """`update --safe` — the hosted-brain path: snapshot, render, verify, and
    roll back to the pre-update commit if the vault would be left unhealthy.

    Implies --no-pull: a container's image already IS the new version, so the
    update is a pure re-render and can never fail halfway through a fetch."""
    from . import __version__, selfupdate
    from .selfupdate import safe_update
    vault = Path(args.vault).expanduser().resolve()

    def _render(v):
        inner = argparse.Namespace(vault=str(v), no_pull=True,
                                   stamp=getattr(args, "stamp", None), safe=False)
        detail = {}
        rc = cmd_update(inner, report_out=detail)
        if rc != 0:
            raise RuntimeError(f"render exited {rc}")
        return detail                    # → merged into .state/update-status.json

    def _snapshot(v):
        """Best-effort off-host snapshot through the configured durability backend.

        Never blocks the update (the git rollback is the real guarantee) but it
        must never fail *silently* either: a quietly-skipped snapshot is a safety
        net you only discover was missing when you need it."""
        try:
            from alexandria_vault.mcp.durability import make_backend
            from alexandria_vault.mcp.gitops import VaultWriter
            from . import config as cfgmod
            cfg = cfgmod.load_config(str(v)) or {}
            backend = make_backend(cfg)
            if backend.kind in ("none", "volume"):
                print(f"  snapshot: skipped (durability backend is '{backend.kind}')")
                return False
            ok = bool(backend.push(VaultWriter(v, config=cfg)))
            print(f"  snapshot: {'pushed via ' + backend.kind if ok else backend.kind + ' push returned false'}")
            return ok
        except Exception as e:                   # noqa: BLE001 - never block the update
            print(f"  snapshot: unavailable ({e.__class__.__name__}: {e})")
            return False

    res = safe_update(vault, _render, snapshot=_snapshot, target_version=__version__)
    print(json.dumps(res, indent=2))
    if res.get("action") == "already-current":
        print(f"Core already at {__version__} — nothing to do.")
        return 0
    if res.get("ok"):
        print(f"Core updated to {__version__}"
              + (" (committed)" if res.get("committed") else " (no changes)"))
        return 0
    print(f"Update refused/rolled back: {res.get('reason')}", file=sys.stderr)
    for p in res.get("problems") or []:
        print(f"  - {p}", file=sys.stderr)
    # Rolled back cleanly = the brain is still healthy on its old Core. That is a
    # warning, not a crash: exit 0 so a container keeps booting and serving.
    return 0 if res.get("action") == "rolled-back" and res.get("rolled_back") else 1


def _advise_nested_zip_layout(vault) -> None:
    """Point v3.0 single-folder installs at the root-layout migration.

    v3.0 zips nested the vault in ``Brain/`` one level below the session folder,
    so every link, skill, and path needed a ``Brain/`` prefix the agent had to
    supply by discipline. v3.1 renders the brain at the folder root. The move
    itself reshapes the *parent* of this vault, so it is deliberately not done
    here — the new zip's installer prompt owns it (its MIGRATE arc)."""
    v = Path(vault).resolve()
    if v.name == "Brain" and (v.parent / "CLAUDE.md").is_file():
        print("NOTE: this vault uses the v3.0 nested layout (Brain/ inside the install "
              "folder). v3.1 installs the brain at the folder root, where links and "
              "skills resolve without the Brain/ prefix. To migrate: download the "
              "latest Alexandria.zip, move this Brain/ into the unzipped folder, open "
              "Claude Code there, and say \"migrate\" — notes and git history move up "
              "intact.")


def _migrate_legacy_folder(vault) -> None:
    """One-time: vaults from before the Alexandria/ -> Agent/ rename keep their system
    folder (Core machinery + the owner's Config) at Alexandria/. Move it to Agent/ so
    update refreshes Core in the new location while carrying Config across untouched."""
    legacy, new = Path(vault) / "Alexandria", Path(vault) / "Agent"
    if not legacy.is_dir() or new.exists():
        return
    import subprocess, shutil
    moved = False
    if (Path(vault) / ".git").exists():
        moved = subprocess.run(["git", "-C", str(vault), "mv", "Alexandria", "Agent"],
                               capture_output=True).returncode == 0
    if not moved:
        shutil.move(str(legacy), str(new))
    print("Migrated system folder: Alexandria/ -> Agent/ (Config carried over).")


def _save_config(vault, cfg):
    (Path(vault) / "Agent" / "config.json").write_text(
        json.dumps(cfg, indent=2) + "\n", encoding="utf-8")


def _rerender(vault, cfg, ids, stamp):
    from . import modules as modmod, render, manifest
    module_dirs = [modmod.template_dir(i) for i in ids]
    build, minfo = _build_and_modules(ids)
    written = render.render_vault(vault, module_dirs, cfg, build=build, modules_info=minfo)
    manifest.write_state(vault, written, cfg,
                         (cfg.get("alexandria") or {}).get("template_ref"), stamp=stamp, build=build)
    return written


# One build per process: several renders (or a test suite full of them) reuse the
# same freshly-built wheel instead of invoking pip once per vault.
_WHEEL_MEMO = {}


def _vendor_engine_wheel(vault) -> bool:
    """Build the installed app into a wheel and place it in the vault at
    `.github/engine/` — the scheduled workflows install the engine FROM THE
    CHECKOUT ITSELF, so CI needs no software-repo fetch and no
    `ALEXANDRIA_REPO_DIST_PAT`. The pin is the exact bytes: the wheel travels
    with the vault and is refreshed here whenever the rendered version changes.
    Non-fatal on failure (the workflow errors clearly at run time instead).
    Returns True when the vendored wheel changed."""
    import shutil, subprocess, tempfile
    from . import __version__
    dest = Path(vault) / ".github" / "engine"
    want = f"alexandria_vault-{__version__}-py3-none-any.whl"
    if (dest / want).is_file():
        return False
    built = _WHEEL_MEMO.get(__version__)
    if not (built and Path(built).is_file()):
        app_root = Path(__file__).resolve().parents[1]
        out_dir = Path(tempfile.mkdtemp(prefix="alexandria-wheel-"))
        r = subprocess.run([sys.executable, "-m", "pip", "wheel", "--quiet",
                            "--no-deps", "--no-build-isolation",
                            "-w", str(out_dir), str(app_root)],
                           capture_output=True, text=True)
        wheels = sorted(out_dir.glob("alexandria_vault-*.whl"))
        if r.returncode != 0 or not wheels:
            print("  WARNING: could not build the engine wheel to vendor at "
                  ".github/engine/ (the scheduled workflows install it from there):\n  "
                  + (r.stderr or r.stdout).strip()[-600:])
            return False
        built = str(wheels[-1])
        _WHEEL_MEMO[__version__] = built
    dest.mkdir(parents=True, exist_ok=True)
    for old in dest.glob("*.whl"):
        old.unlink()
    shutil.copyfile(built, dest / Path(built).name)
    print(f"  vendored engine wheel: .github/engine/{Path(built).name}")
    return True


def _maybe_vendor_engine_wheel(vault, ids) -> None:
    """Vendor the wheel wherever the laptop CI runner is part of the render."""
    if "laptop-github-actions" in (ids or []):
        _vendor_engine_wheel(vault)


def cmd_seed_area(args) -> int:
    """Create one Area's scaffold in an existing vault.

    The technician install renders a neutral, *nameless* vault (no ``owner.name``, so no
    personal Area). Once the owner gives their name in the settings first-run ("Hello"), the
    agent runs this to build their personal Area — ``Areas/<name>/`` + the ``Concepts/``
    scaffold — using the real render engine (so it always matches current Core). The engine
    path is ``render.seed_personal_area`` (shared with the vault-MCP ``seed_area`` tool): a
    one-Area, owner-named overlay over the **content** bucket, write-once — it only ever
    *creates*, never clobbers the owner's notes.
    """
    from . import config as cfgmod, render
    vault = Path(args.vault).expanduser()
    _migrate_legacy_folder(vault)
    cfg = cfgmod.load_config(str(vault))
    if not cfg:
        print("Not an Alexandria vault.")
        return 1
    name = (args.name or "").strip()
    if not name:
        print("--name is required (the Area name, e.g. the owner's full name).")
        return 1
    try:
        written = render.seed_personal_area(vault, cfg, name)
    except ValueError as e:
        print(str(e))
        return 1
    created = [w["path"] for w in written if w.get("written")]
    if created:
        print(f"Seeded Area '{name}' — created {len(created)} file(s):")
        for p in created:
            print("  " + p)
    else:
        print(f"Area '{name}' already present — nothing to create (content is write-once).")
    return 0


# Modules that ARE the email channel (runners that schedule sends, inbound mail,
# per-person briefs). Not available on a local (folder) brain — there is no way to
# stand up an email channel on one for now; these are runbook/hosted-tier features.
# NOTE: federation is intentionally absent — the V4 relay push channel is core and
# needs no email, so it works on a local brain (§9.4). The legacy V1 email transport
# does need a runner, but it is no longer an enable-able module (RETIRED_MODULES).
EMAIL_CHANNEL_MODULES = frozenset(
    {"laptop-github-actions", "server-macos", "team-digests", "principal-inbox"})


def cmd_enable(args) -> int:
    from . import config as cfgmod, modules as modmod
    vault = Path(args.vault).expanduser()
    _migrate_legacy_folder(vault)
    if args.module in modmod.RETIRED_MODULES:
        # federation became core in 4.0.0 — the relay push capability is always present,
        # on every brain including local ones (§9.4). There is nothing to enable; pairing
        # is the only step. (The legacy V1 email transport rides the literal "federation"
        # string an older vault already carries; we don't re-add it via enable.)
        print(f"'{args.module}' is part of core now — always present, nothing to enable. "
              "Pair with a peer to start: `alexandria peer invite \"<name>\"`.")
        return 0
    if (args.module in EMAIL_CHANNEL_MODULES
            and (vault / ".alexandria" / "app").is_dir()):
        print(f"'{args.module}' is an email-channel feature — not available on a local "
              "brain: there is no way to set up an email channel on one for now "
              "(runbook/hosted-tier feature).")
        return 1
    cfg = cfgmod.load_config(str(vault))
    if not cfg:
        print("Not an Alexandria vault.")
        return 1
    enabled = cfgmod.enabled_modules(cfg)
    if args.module in enabled:
        print(f"'{args.module}' is already enabled.")
        return 0
    new = enabled + [args.module]
    ids, probs = modmod.resolve(new)
    if _print_problems(probs):
        print(f"\nNot enabling '{args.module}'.")
        return 1
    # Persist the RESOLVED list, core included — doctor, config.example, and the
    # install profiles all treat "core" as an explicit member of modules.enabled;
    # stripping it here left a doctor WARN behind after every enable/disable.
    cfg.setdefault("modules", {})["enabled"] = list(ids)
    _save_config(vault, cfg)
    _rerender(vault, cfg, ids, args.stamp)
    _maybe_vendor_engine_wheel(vault, ids)
    print(f"Enabled '{args.module}'. Modules: {', '.join(ids)}.")
    print("Re-rendered Core + any new seeds. Set up new secrets (Agent/SECRETS.md), "
          "then run `alexandria doctor`.")
    return 0


def cmd_disable(args) -> int:
    from . import config as cfgmod, modules as modmod
    vault = Path(args.vault).expanduser()
    _migrate_legacy_folder(vault)
    if args.module == "core":
        print("Cannot disable 'core'.")
        return 1
    cfg = cfgmod.load_config(str(vault))
    if not cfg:
        print("Not an Alexandria vault.")
        return 1
    enabled = cfgmod.enabled_modules(cfg)
    if args.module not in enabled:
        print(f"'{args.module}' is not enabled.")
        return 0
    new = [m for m in enabled if m != args.module]
    ids, probs = modmod.resolve(new)
    if _print_problems(probs):
        print(f"\nCannot disable '{args.module}' — other enabled modules depend on it.")
        return 1
    cfg.setdefault("modules", {})["enabled"] = list(ids)   # core included — see cmd_enable
    _save_config(vault, cfg)
    _rerender(vault, cfg, ids, args.stamp)
    _maybe_vendor_engine_wheel(vault, ids)
    print(f"Disabled '{args.module}'. Modules: {', '.join(ids)}.")
    print(f"Note: files previously rendered by '{args.module}' remain in the vault; "
          "remove them manually if you want them gone.")
    return 0


# --- server-macos launchd install ------------------------------------------------------
# Each rendered plist under Agent/scripts/launchd/ carries one __*_SH__ placeholder for the
# absolute path of the wrapper it runs; this substitutes from the vault path, then loads it.
_LAUNCHD_SCRIPTS = {
    "__SWEEP_SH__":      "Agent/scripts/sweep/sweep.sh",
    "__LISTENER_SH__":   "Agent/scripts/daily-digest/agent-listener.sh",
    "__VAULT_SYNC_SH__": "Agent/scripts/sweep/vault-sync.sh",
    "__HEALTH_SH__":     "Agent/scripts/daily-digest/health-check.sh",
    "__VAULT_MCP_SH__":  "Agent/scripts/mcp/vault-mcp.sh",   # vault-mcp module (P3)
}


def _plan_launchd(vault) -> list:
    """Resolve the server-macos launchd jobs for a rendered vault — pure (no writes, no
    launchctl). Returns [{name, text, target, missing}]: ``text`` has every ``__*_SH__``
    placeholder replaced with an absolute path, ``target`` is the ~/Library/LaunchAgents
    destination, and ``missing`` lists any referenced wrapper absent from the vault."""
    import re
    vault = Path(vault).expanduser().resolve()
    srcdir = vault / "Agent" / "scripts" / "launchd"
    launch_agents = Path.home() / "Library" / "LaunchAgents"
    jobs = []
    for src in (sorted(srcdir.glob("com.alexandria.*.plist")) if srcdir.is_dir() else []):
        text = src.read_text(encoding="utf-8")
        # Drop the template's instructional XML comments from the installed copy — they
        # describe the old manual sed step and would otherwise get the placeholder spliced
        # mid-sentence. launchd ignores comments; the template keeps them for readers.
        text = re.sub(r"[ \t]*<!--.*?-->\n?", "", text, flags=re.DOTALL)
        missing = []
        for token, rel in _LAUNCHD_SCRIPTS.items():
            if token in text:
                if not (vault / rel).exists():
                    missing.append(rel)
                text = text.replace(token, str(vault / rel))
        text = text.replace("__LOGDIR__", str(Path.home() / "Library" / "Logs" / "alexandria"))
        jobs.append({"name": src.name, "text": text,
                     "target": launch_agents / src.name, "missing": missing})
    return jobs


def _apply_launchd(jobs, no_load=False):
    """Write each planned plist to ~/Library/LaunchAgents and (unless no_load) reload it —
    unload-then-load, so it's idempotent and restarts the long-lived listener. Returns the
    number of load failures."""
    import subprocess
    (Path.home() / "Library" / "LaunchAgents").mkdir(parents=True, exist_ok=True)
    (Path.home() / "Library" / "Logs" / "alexandria").mkdir(parents=True, exist_ok=True)
    failures = 0
    for j in jobs:
        j["target"].write_text(j["text"], encoding="utf-8")
        if no_load:
            print(f"  {j['name']}: written (not loaded)")
            continue
        subprocess.run(["launchctl", "unload", str(j["target"])], capture_output=True)
        r = subprocess.run(["launchctl", "load", str(j["target"])], capture_output=True, text=True)
        if r.returncode == 0:
            print(f"  {j['name']}: loaded")
        else:
            failures += 1
            print(f"  {j['name']}: load FAILED — {(r.stderr or r.stdout).strip()}")
    return failures


def _reload_launchd_after_update(vault, cfg):
    """After `update` re-renders Core on a server-macos host, refresh the installed plists and
    restart the jobs so the long-lived agent-listener runs the new code (the interval jobs
    already respawn fresh each tick). No-op unless the jobs are installed here — so it does
    nothing on the laptop / in CI, only on a real appliance host."""
    import shutil
    if (cfg.get("automation") or {}).get("runner") != "server-macos":
        return
    if not shutil.which("launchctl"):
        return
    if not list((Path.home() / "Library" / "LaunchAgents").glob("com.alexandria.*.plist")):
        return  # not installed on this host — nothing to reload
    jobs = _plan_launchd(vault)
    if jobs:
        print("Reloading launchd jobs (so the inbox listener picks up the update):")
        _apply_launchd(jobs)


# --- F14: an opt-in launchd timer so a worker-less local brain still receives ----------
# A served brain / runner auto-ticks; a bare folder/zip brain has nothing polling, so a
# peer's push can sit at the relay for days. This installs a per-user launchd timer that
# runs `federation tick` on an interval — self-contained (it does NOT need the server-macos
# module's rendered plists, which a local brain doesn't have).
def _fed_timer_label(vault):
    import hashlib
    h = hashlib.sha256(str(Path(vault).expanduser().resolve()).encode()).hexdigest()[:8]
    return f"com.alexandria.federation-tick.{h}"        # per-vault, so re-install is idempotent


def _fed_timer_target(vault):
    return Path.home() / "Library" / "LaunchAgents" / f"{_fed_timer_label(vault)}.plist"


def _fed_timer_program_args(vault):
    """What the timer runs: the installed brain's alexandria wrapper if present, else this
    module via the current interpreter (dev/non-installed)."""
    vault = Path(vault).expanduser().resolve()
    wrapper = vault / ".alexandria" / "app" / ".venv" / "bin" / "alexandria"
    base = [str(wrapper)] if wrapper.exists() else [sys.executable, "-m", "scripts.cli"]
    return base + ["federation", "tick", "--vault", str(vault)]


def _fed_timer_plist(label, program_args, interval, logdir):
    import plistlib
    return plistlib.dumps({
        "Label": label,
        "ProgramArguments": list(program_args),
        "StartInterval": int(interval),
        "RunAtLoad": True,                              # tick once at install/login, then on interval
        "StandardOutPath": str(logdir / f"{label}.out.log"),
        "StandardErrorPath": str(logdir / f"{label}.err.log"),
        "ProcessType": "Background",
    }).decode("utf-8")


def cmd_install_launchd(args) -> int:
    """Install + load the server-macos launchd jobs. Substitutes each wrapper's absolute
    path into the rendered plists, writes them to ~/Library/LaunchAgents, and (re)loads
    them. Run this ON the always-on host; idempotent, so it's safe to re-run after update."""
    import subprocess
    vault = Path(args.vault).expanduser()
    srcdir = vault / "Agent" / "scripts" / "launchd"
    if not srcdir.is_dir():
        print(f"No launchd templates at {srcdir}. Enable the server-macos module and run "
              "`alexandria init`/`update` first (run this command on the always-on host).")
        return 1
    jobs = _plan_launchd(vault)
    if not jobs:
        print(f"No com.alexandria.*.plist found under {srcdir}.")
        return 1

    for j in jobs:
        for rel in j["missing"]:
            print(f"WARNING: {j['name']} points at {rel}, which is missing in the vault.")

    launch_agents = Path.home() / "Library" / "LaunchAgents"
    if args.dry_run:
        print(f"Dry run — would install {len(jobs)} job(s) into {launch_agents}:")
        for j in jobs:
            print(f"  {j['name']}  ->  {j['target']}")
        return 0

    failures = _apply_launchd(jobs, no_load=args.no_load)
    print(f"\n{'Wrote' if args.no_load else 'Installed'} {len(jobs)} launchd job(s) "
          f"in {launch_agents}.")
    if not args.no_load:
        print("Verify with:  launchctl list | grep com.alexandria")
    return 1 if failures else 0


def cmd_harden(args) -> int:
    """Always-on appliance host hardening (needs root): never sleep on AC + restart after a
    power failure. Idempotent. Auto-login stays manual (it needs the login password and a
    FileVault decision). Run:  sudo "$(command -v alexandria)" harden"""
    import os
    import subprocess
    import sys
    if sys.platform != "darwin":
        print("`alexandria harden` is macOS-only.")
        return 1
    if os.geteuid() != 0:
        print('Host hardening needs root — run:  sudo "$(command -v alexandria)" harden')
        return 1
    steps = [
        (["/usr/bin/pmset", "-c", "sleep", "0", "disksleep", "0"], "never sleep on AC power"),
        (["/usr/sbin/systemsetup", "-setrestartpowerfailure", "on"], "restart after a power failure"),
    ]
    rc = 0
    for cmd, desc in steps:
        try:
            r = subprocess.run(cmd, capture_output=True, text=True)
        except OSError as e:
            print(f"  ✗ {desc}: {e}")
            rc = 1
            continue
        if r.returncode == 0:
            print(f"  ✓ {desc}")
        else:
            print(f"  ✗ {desc}: {(r.stderr or r.stdout or '').strip() or 'failed'}")
            rc = 1
    print("\nStill manual (it needs your login password + a FileVault decision):")
    print("  • Auto-login — System Settings → Users & Groups → Automatically log in as this user.")
    print("    macOS blocks auto-login while FileVault is ON; decide FileVault-off (auto-boot) vs")
    print("    FileVault-on (enter the disk key on reboot) deliberately.")
    return rc


def cmd_banner(args) -> int:
    from . import banner
    vault = getattr(args, "vault", None)
    print(banner.from_vault(Path(vault).expanduser()) if vault else banner.render())
    return 0


# `alexandria dashboard` — the localhost config web UI — was removed in 2.103.0.
# It was a second, parallel way to edit config.json that had to be kept in step
# with the CLI and the interview by hand, and nobody used it: the owner is either
# on the box (where the CLI is faster) or in the desktop app (which owns this job
# properly). `git log -- scripts/dashboard.py docs/CONFIG-DASHBOARD.md` has the
# implementation and its spec if the shape is ever wanted again.


def cmd_serve(args) -> int:
    """Run the brain layer as a server — a discoverable alias for the vault-mcp
    daemon (`python -m alexandria_vault.mcp`). Same flags, same behavior; the
    launchd wrapper keeps exec-ing the module form (docs/V3-PLAN.md §4 P3)."""
    from alexandria_vault.mcp.__main__ import main as mcp_main
    argv = []
    if args.vault:
        argv += ["--vault", args.vault]
    if args.host:
        argv += ["--host", args.host]
    if args.port is not None:   # 0 = ephemeral (sidecar) — falsy but meaningful
        argv += ["--port", str(args.port)]
    if args.no_tailscale:
        argv += ["--no-tailscale"]
    if args.no_sync:
        argv += ["--no-sync"]
    if args.exit_with_parent:
        argv += ["--exit-with-parent"]
    return mcp_main(argv)


def _load_vault_config(vault):
    import json as _json
    from pathlib import Path as _Path
    for rel in ("Agent/config.json", "Alexandria/config.json"):
        p = _Path(vault) / rel
        if p.is_file():
            return _json.loads(p.read_text(encoding="utf-8"))
    return {}


def cmd_vault(args) -> int:
    """`backup` / `restore` over the configured durability backend — the seam the
    brain entrypoint and ops use so object-store and git-remote hydrate/snapshot
    the same way, without embedding git/boto3 specifics in bash. `restore` runs
    on an EMPTY target (first boot / DR); `backup` snapshots the current vault;
    `stored-head` prints the tip the off-host copy records, so the entrypoint can
    tell a current checkout from a stale one without downloading anything."""
    from pathlib import Path
    from alexandria_vault.mcp.durability import make_backend
    vault = Path(args.vault or ".").resolve()

    if args.vault_cmd == "restore":
        backend = make_backend(_load_vault_config(vault) if (vault / "Agent").exists()
                               else _restore_config(args))
        try:
            ok = backend.restore(vault)
        except Exception as e:
            print(f"vault restore: {e}", file=sys.stderr)
            return 1
        print(f"vault restore: {'restored from ' + backend.kind if ok else 'no off-host snapshot — nothing to restore'}")
        return 0 if ok else 3       # 3 = clean 'nothing there', so callers render fresh

    if args.vault_cmd == "stored-head":
        # Read-only: print the commit the off-host copy records as its tip, so a
        # caller can test a local checkout against it without downloading it.
        # Same exit-3 convention as `restore` — "nothing off-host to compare to"
        # is a clean answer, not a failure.
        backend = make_backend(_load_vault_config(vault) if (vault / "Agent").exists()
                               else _restore_config(args))
        try:
            head = backend.stored_head()
        except Exception as e:
            print(f"vault stored-head: {e}", file=sys.stderr)
            return 1
        if not head:
            return 3
        print(head)
        return 0

    # backup
    from alexandria_vault.mcp.gitops import VaultWriter
    if not (vault / ".git").exists():
        print(f"vault backup: {vault} is not a git repo", file=sys.stderr)
        return 2
    writer = VaultWriter(vault, config=_load_vault_config(vault))
    ok = writer.durability.push(writer)
    print(f"vault backup: {'snapshot pushed via ' + writer.durability.kind if ok else 'backend is local-only (nothing pushed)'}")
    return 0


def _restore_config(args):
    """For `restore` onto a truly empty target, the durability config can't come
    from the (absent) vault — read it from --config, else env-only defaults.

    `os` is imported at module scope for this. It used to be imported only inside
    the handful of functions that needed it, and this one read `os.environ`
    without doing so — which raised `NameError` the first time the line was
    reached. That was not caught for a long time because this is the *empty
    target* path: it runs only when a brain restores onto a disk with no vault on
    it, which on a compose rack is rare and on a volume-free Railway brain is
    EVERY first boot. The first tenant ever born through the Railway driver
    crash-looped on it (2026-07-26)."""
    import json as _json
    if getattr(args, "config", None):
        return _json.loads(open(args.config, encoding="utf-8").read())
    return {"vault": {"durability": {"backend":
            os.environ.get("VAULT_DURABILITY_BACKEND", "git-remote")}}}


def cmd_features(args) -> int:
    """`features list` / `features apply` — the opt-in capability surface.

    A brain is quiet unless it was created with a feature; this is where an
    operator sees what the switches are and turns one on."""
    from . import features as feat
    if args.features_cmd == "list":
        for name in sorted(feat.FEATURES):
            spec = feat.FEATURES[name]
            print(f"{name:10} {spec['summary']}")
            for need in spec["needs"]:
                print(f"           · needs {need}")
        return 0
    try:
        enabled, notes = feat.apply_to_vault(args.vault, args.features)
    except feat.FeatureError as e:
        print(f"features: {e}", file=sys.stderr)
        return 2
    if not enabled:
        print("features: none requested — the brain stays quiet (nothing changed).")
        return 0
    print(f"features enabled: {', '.join(enabled)}")
    for n in notes:
        print(f"  still needed — {n}")
    return 0


def _tool_seat_and_service(args):
    """A (seat, service) pair for shell-local tool calls. The caller already has
    the filesystem, so seat auth is synthesized through the constructor's env
    seam (never the real environment): shell access to the vault IS ownership.
    --read-only narrows the synthesized seat, mirroring a read-only MCP seat."""
    from alexandria_vault.mcp.seats import READ_ONLY, READ_WRITE, Seat
    from alexandria_vault.mcp.service import VaultMCPService
    svc = VaultMCPService(getattr(args, "vault", None),
                          env={"VAULT_MCP_BEARER": "shell-local"})
    scope = READ_ONLY if getattr(args, "read_only", False) else READ_WRITE
    return Seat(id="shell", scope=scope), svc


def cmd_import(args) -> int:
    """Bulk-import a directory of documents into the vault.

    The shell face of the same engine the ``import_start`` tool drives
    (``alexandria_vault.engine.vault_import``), so a technician at a checkout and
    a hosted tenant in Orion get identical routing. The difference is only the
    source: a host directory here, the brain's file space there.
    """
    from alexandria_vault.engine import vault_import as _imp
    src = Path(args.source).expanduser()
    if not src.is_dir():
        print(f"import: not a directory — {src}", file=sys.stderr)
        return 2
    try:
        seat, svc = _tool_seat_and_service(args)
    except Exception as e:
        print(f"import: cannot open the vault — {e}", file=sys.stderr)
        return 2

    seen = {"n": 0}

    def progress(info):
        seen["n"] = info["scanned"]
        if not args.quiet:
            print(f"  [{info['scanned']}] {info['current']}", file=sys.stderr)

    try:
        report = _imp.run(svc, _imp.walk_dir(str(src)), seat_id=seat.id,
                          instructions=args.instructions, plan_only=args.plan,
                          limit=args.limit, progress=progress)
    except _imp.ImportError_ as e:
        print(f"import: {e}", file=sys.stderr)
        return 1
    if args.json:
        print(json.dumps(report, indent=2))
        return 0
    print(report["tldr"])
    b = report["bands"]
    print(f"  confidence: high {b['high']} · med {b['med']} · low {b['low']}")
    if report["staged_paths"]:
        print(f"  queued in Agent/inbox/ ({len(report['staged_paths'])}) — "
              "drain with 'process my inbox':")
        for p in report["staged_paths"][:10]:
            print(f"    {p}")
        if len(report["staged_paths"]) > 10:
            print(f"    … and {len(report['staged_paths']) - 10} more")
    for f in report["failures"][:10]:
        print(f"  skipped {f['name']}: {f['error']}", file=sys.stderr)
    if args.plan:
        print("\nThis was a dry run — nothing was written. Re-run without --plan.")
    return 0


def cmd_tool(args) -> int:
    """The bash surface of the brain layer: list/inspect/call the exact tool
    registry vault-mcp serves (alexandria_vault/mcp/tools.py) — same schemas,
    same seat gating, same ACL/jail — with no server process required."""
    from alexandria_vault.mcp.tools import ToolError, call_tool, list_tools

    def _alias_target(name, by_name):
        """The listed tool a former name dispatches to, or None."""
        from alexandria_vault.mcp.tools import _BY_NAME
        entry = _BY_NAME.get(name)
        return by_name.get(entry["name"]) if entry is not None else None

    try:
        seat, svc = _tool_seat_and_service(args)
    except Exception as e:
        print(f"tool: cannot open the vault — {e}", file=sys.stderr)
        return 2
    if args.tool_cmd == "list":
        tools = list_tools(svc, seat)
        if args.json:
            print(json.dumps(tools, indent=2))
        else:
            for t in tools:
                print(f"{t['name']:<22} {t['description'].splitlines()[0]}")
        return 0
    if args.tool_cmd == "schema":
        by_name = {t["name"]: t for t in list_tools(svc, seat)}
        t = by_name.get(args.name)
        if t is None:
            # Resolve former names too: `tool call` dispatches them (they are
            # registry aliases), so `tool schema` refusing them would be a surface
            # that disagrees with itself. Name the survivor on the way through.
            t = _alias_target(args.name, by_name)
            if t is not None:
                print(f"tool: {args.name!r} was renamed to {t['name']!r} — still "
                      f"callable, showing the current schema", file=sys.stderr)
        if t is None:
            print(f"tool: unknown tool {args.name!r} (see `alexandria tool list`)",
                  file=sys.stderr)
            return 1
        print(json.dumps(t, indent=2))
        return 0
    # call
    try:
        call_args = json.loads(args.args) if args.args else {}
        if not isinstance(call_args, dict):
            raise ValueError("must be a JSON object")
    except ValueError as e:
        print(f"tool: --args must be a JSON object — {e}", file=sys.stderr)
        return 2
    try:
        result = call_tool(svc, seat, args.name, call_args)
    except ToolError as e:
        print(f"tool: {e}", file=sys.stderr)
        return 1
    print(json.dumps(result, indent=2, ensure_ascii=False))
    return 0


# ---- V4 federation (peer pairing + transport) ------------------------------
def _fed_setup(args, *, need_relay=True):
    """Resolve (vault, config, relay_client) for a federation command."""
    from . import config as cfgmod
    vault = Path(getattr(args, "vault", None) or ".").expanduser()
    config = cfgmod.load_config(str(vault))
    # Federation is core, so a vault without a federation block is still push-capable:
    # seed the default relay (a pre-4.0 vault's config.json is never rewritten by
    # update). When the seed ADDED relay_url, PERSIST the block to config.json: the
    # served worker reads config from disk and won't start without a persisted
    # relay_url — and pairing is CLI-only, so every peer/federation command funnels
    # through here before a peer can exist, making this the one choke point where an
    # upgraded vault's block can materialize. A block that already carries a
    # relay_url key (any value — an explicit null is an opt-out) is left
    # byte-for-byte alone: no churn, no clobbering of owner values.
    fed_before = config.get("federation")
    relay_absent = fed_before is None or (
        isinstance(fed_before, dict) and "relay_url" not in fed_before)
    cfgmod.ensure_federation_defaults(config)
    fed_now = config.get("federation")
    if relay_absent and isinstance(fed_now, dict) and fed_now.get("relay_url"):
        cfg_file = Path(cfgmod.config_path(str(vault)))
        if cfg_file.exists():                    # never invent a config.json (not a vault)
            from alexandria_vault.federation import pairing
            try:
                pairing._write_config_atomically(cfg_file, config)
                print(f"federation: persisted the default federation block "
                      f"(relay {fed_now['relay_url']}) to Agent/config.json — "
                      "the served worker starts from this.")
            except OSError as e:
                print(f"federation: could not persist the federation block to "
                      f"{cfg_file} ({e}) — the served worker won't start until "
                      "federation.relay_url is written there.", file=sys.stderr)
    from alexandria_vault.federation import birth
    url = (config.get("federation") or {}).get("relay_url")
    if need_relay and not url:
        raise SystemExit("federation: set federation.relay_url in Agent/config.json first.")
    # One client for the whole command — built through birth.make_client so the
    # birth announcement and the worker's poll share it (and the test suite's
    # in-process/offline seam covers both; nothing here reaches the network on its own).
    return vault, config, (birth.make_client(url) if url else None)


def _print_words_and_next(r):
    """Show the three verification words for a ready handshake + the two explicit next
    steps. The word check is the human trust anchor (§2.2): a deliberate separate step
    (`peer confirm` / `peer abort`), never a y/n prompt an agent would answer blindly —
    which is exactly what lets Alexandria drive the ceremony while the human owns the check."""
    print(f"\nVerification words for @{r['handle']} ({r.get('peer_name') or 'the peer'}) — read "
          "these THREE aloud; you and your peer must see the SAME three:\n")
    print("    " + "  ".join(r["words"]) + "\n")
    # State the consent plainly (F13): confirming grants what this peer may send you.
    grant = r.get("grant") or {}
    _label = {"todo": "todos", "note": "notes", "knowledge": "knowledge", "event": "events"}
    _kinds = ", ".join(_label.get(i, i) for i in (grant.get("intents") or [])) or "nothing"
    _areas = grant.get("areas")
    _scope = f" (into {', '.join(_areas)})" if _areas else ""
    print(f"Confirming lets them send you: {_kinds}{_scope} — you can narrow this later.\n")
    print(f"If they MATCH on both sides:              alexandria peer confirm {r['handle']}")
    print(f"If they DON'T (a possible interception):  alexandria peer abort {r['handle']}")


def _print_inbox(res):
    """Render a mailbox peek: the count split into content vs protocol so a stack of
    receipts never reads as unread mail, then one block per waiting message."""
    st = res.get("status")
    if st == "no-identity":
        print("No federation identity yet — pair with someone first (`alexandria peer invite`).")
        return
    if st == "no-mailbox":
        print("No relay mailbox yet — it's registered when you first pair with a peer.")
        return
    if st == "unreachable":
        print(f"peer: relay unreachable — {res.get('reason', '')}".rstrip(), file=sys.stderr)
        return
    msgs = res.get("messages") or []
    if not msgs:
        print("Your relay inbox is empty — nothing waiting.")
        return
    c, p = res.get("content", 0), res.get("protocol", 0)
    print(f"Relay inbox — {len(msgs)} waiting: {c} content, {p} protocol (receipts/pings). "
          "Nothing is applied until your next tick.\n")
    for m in msgs:
        when = (m.get("at") or "")[:16].replace("T", " ")
        print(f"  [{m.get('kind') or '?':<7}] {m.get('sender'):<18} {when}")
        print(f"      {m.get('preview')}")
        print(f"      → {m.get('summary')}")
    print("\nRun `alexandria federation tick` to process these.")


def cmd_peer(args) -> int:
    try:
        from alexandria_vault.federation import pairing, outbox, FederationUnavailable
        from alexandria_vault.federation import relay_client as rc
    except Exception as e:  # noqa: BLE001 — missing extra etc.
        print(f"peer: federation unavailable — {e}", file=sys.stderr)
        return 2
    if args.peer_cmd == "remove":
        vault, config, _ = _fed_setup(args, need_relay=False)
        url = (config.get("federation") or {}).get("relay_url")
        client = rc.RelayClient(url) if url else None
        try:
            peer = pairing.remove_peer(vault, args.handle, relay_client=client, cfg=config)
        except pairing.RevokeUnconfirmed as e:
            # The relay did not confirm the token revoke, so NOTHING was dropped
            # locally (the stored sha is the only handle that can ever revoke the
            # still-live token) — report the truth, never a false "removed".
            print(f"peer: {e}", file=sys.stderr)
            return 1
        except pairing.PairingError as e:
            print(f"peer: {e}", file=sys.stderr)
            return 1
        print(f"Removed peer @{peer['handle']} ({peer.get('name')}); revoked its "
              "deposit token.")
        return 0
    if args.peer_cmd == "list":
        vault, config, _ = _fed_setup(args, need_relay=False)
        peers = config.get("peers") or []
        if not peers:
            print("No peers yet. `alexandria peer invite \"<name>\"` to pair.")
            return 0
        for p in peers:
            st = outbox.status(p.get("fingerprint")) if p.get("fingerprint") else {}
            print(f"@{p.get('handle'):<16} {p.get('name','')}  {p.get('fingerprint','')}")
            if p.get("people_note"):                 # F17: the linked People note
                print(f"    person: [[{p['people_note']}]]")
            if st:
                print(f"    outbox: {st}")
        # F18: verification words are NOT printed here — re-check on demand with
        # `alexandria peer verify <handle>`.
        return 0
    if args.peer_cmd == "verify":
        from alexandria_vault.federation import surfaces
        vault, config, _ = _fed_setup(args, need_relay=False)
        words = surfaces.verification_words(config, args.handle)
        if not words:
            print(f"peer: no peer @{args.handle} (or this brain has no identity yet).",
                  file=sys.stderr)
            return 1
        print(f"Verification words for @{args.handle}:  {'  '.join(words)}")
        print("Compare these with your peer over a channel you trust to re-confirm you're "
              "connected to the right brain. They're a public fingerprint, not a secret.")
        return 0
    if args.peer_cmd == "pending":
        vault, config, _ = _fed_setup(args, need_relay=False)
        items = pairing.pending_pairings(vault)
        if not items:
            print("No pairings in progress.")
            return 0
        for it in items:
            if it["status"] == "waiting":
                print(f"@{it['handle']} ({it.get('name')}) — invited; waiting for them to "
                      f"redeem the code. Next:  alexandria peer check {it['handle']}")
            else:
                print(f"@{it['handle']} ({it.get('name')}) — words ready: "
                      f"{'  '.join(it.get('words') or [])}")
                print(f"    match on both sides?  alexandria peer confirm {it['handle']}   "
                      f"else  alexandria peer abort {it['handle']}")
        return 0
    if args.peer_cmd == "confirm":
        vault, config, _ = _fed_setup(args, need_relay=False)
        try:
            peer = pairing.confirm_pending(vault, args.handle)
        except pairing.PairingError as e:
            print(f"peer: {e}", file=sys.stderr)
            return 1
        print(f"Paired with {peer.get('name')} (@{peer['handle']}). You can push to them now, "
              "and they to you — a new peer's first items park in Agent/Federation Review.md.")
        return 0
    if args.peer_cmd == "abort":
        vault, config, _ = _fed_setup(args, need_relay=False)
        url = (config.get("federation") or {}).get("relay_url")
        client = rc.RelayClient(url) if url else None
        try:
            ok = pairing.abort_pending(vault, client, args.handle)
        except pairing.PairingError as e:
            print(f"peer: {e}", file=sys.stderr)
            return 1
        if ok:
            print(f"Aborted the pairing with @{args.handle} and revoked the deposit token. "
                  "A word mismatch can mean interception — do NOT retry with the same code.")
            return 0
        print(f"Aborted @{args.handle} locally, but the relay did NOT confirm the token revoke "
              f"— retry `alexandria peer abort {args.handle}` when it's reachable.", file=sys.stderr)
        return 1
    if args.peer_cmd == "inbox":
        from alexandria_vault.federation import surfaces
        vault, config, client = _fed_setup(args)
        if client is None:
            print("peer: no relay configured — nothing to peek at.", file=sys.stderr)
            return 1
        _print_inbox(surfaces.inbox(config, client))
        return 0

    vault, config, client = _fed_setup(args)
    try:
        if args.peer_cmd == "invite":
            r = pairing.start_invite(vault, config, client, args.name, handle=args.handle)
            print("Share this five-word code with your peer over a channel you trust "
                  "(a call or Signal — not email). It expires in 10 minutes:\n")
            print("    " + r["code"] + "\n")
            print(f"Once they've added it, run:  alexandria peer check {r['handle']}")
            return 0
        if args.peer_cmd == "check":
            r = pairing.poll_invite(vault, config, client, args.handle)
            if r["status"] == "waiting":
                print(f"No answer from @{args.handle} yet — have them run "
                      "`alexandria peer add <code>`, then check again in a minute "
                      "(no more often: the relay meters rendezvous reads).")
                return 0
            _print_words_and_next(r)
            return 0
        if args.peer_cmd == "add":
            r = pairing.join(vault, config, client, args.code,
                             name=getattr(args, "name", None),
                             handle=getattr(args, "handle", None))
            _print_words_and_next(r)
            return 0
    except FederationUnavailable as e:                    # PyNaCl missing from the venv
        print(f"peer: {e}", file=sys.stderr)
        return 2
    except pairing.PairingError as e:
        print(f"peer: {e}", file=sys.stderr)
        return 1
    except rc.RelayUnavailable as e:
        print(f"peer: relay unreachable — {e}", file=sys.stderr)
        return 1
    return 2


def cmd_federation(args) -> int:
    try:
        from alexandria_vault.federation import worker, outbox
        from alexandria_vault.federation import relay_client as rc
    except Exception as e:  # noqa: BLE001
        print(f"federation: unavailable — {e}", file=sys.stderr)
        return 2
    if args.fed_cmd == "status":
        vault, config, _ = _fed_setup(args, need_relay=False)
        from alexandria_vault.federation import state as fed_state
        url = (config.get("federation") or {}).get("relay_url")
        mb = fed_state.mailbox_for(url) if url else {}
        print(f"relay:    {url or '(unset)'}")
        print(f"mailbox:  {'registered' if mb.get('bearer') else 'not registered'}")
        print(f"outbox:   {outbox.status() or '(empty)'}")
        # Be honest about liveness (F14): poll_seconds only means anything if SOMETHING
        # ticks — a served brain's worker, a runner, or the opt-in launchd timer.
        runner = (config.get("automation") or {}).get("runner")
        if _fed_timer_target(vault).exists():
            print("autotick: launchd timer installed (this brain receives on its own)")
        elif runner:
            print(f"autotick: via the {runner} runner")
        else:
            print("autotick: none — this brain only receives when you run `federation tick` "
                  "(install a timer: `alexandria federation install-timer`)")
        for p in (config.get("peers") or []):
            if p.get("fingerprint"):
                print(f"  @{p.get('handle')}: {outbox.status(p['fingerprint']) or '(none)'}")
        return 0
    if args.fed_cmd == "fire":
        _fed_setup(args, need_relay=False)               # sets the state home
        r = outbox.fire(args.id)
        row = outbox.get(args.id)
        if not r:
            print(f"federation: push {args.id[:8]} is not `staged` "
                  f"(state: {(row or {}).get('state')}) — nothing to fire.", file=sys.stderr)
            return 1
        print(f"fired {args.id[:8]} — deliver with `alexandria federation tick`.")
        return 0
    if args.fed_cmd == "install-timer":
        import shutil, subprocess
        vault, config, _ = _fed_setup(args, need_relay=False)
        if not shutil.which("launchctl"):
            print("federation: launchctl not found — the auto-tick timer is macOS-only. "
                  "On other hosts, run `alexandria federation tick` from cron.", file=sys.stderr)
            return 1
        poll = (config.get("federation") or {}).get("poll_seconds")
        interval = args.interval or max(300, int(poll or 900))    # floor 5 min for a laptop
        label = _fed_timer_label(vault)
        target = _fed_timer_target(vault)
        logdir = Path.home() / "Library" / "Logs" / "alexandria"
        logdir.mkdir(parents=True, exist_ok=True)
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_text(_fed_timer_plist(label, _fed_timer_program_args(vault), interval,
                                           logdir), encoding="utf-8")
        subprocess.run(["launchctl", "unload", str(target)], capture_output=True)
        r = subprocess.run(["launchctl", "load", str(target)], capture_output=True, text=True)
        if r.returncode != 0:
            print(f"federation: wrote {target} but launchctl load FAILED — "
                  f"{(r.stderr or r.stdout).strip()}", file=sys.stderr)
            return 1
        print(f"Installed the federation auto-tick timer ({target.name}): every "
              f"{interval // 60} min. This brain now receives on its own — no manual tick.\n"
              f"Remove it with:  alexandria federation uninstall-timer")
        return 0
    if args.fed_cmd == "uninstall-timer":
        import shutil, subprocess
        vault, _, _ = _fed_setup(args, need_relay=False)
        target = _fed_timer_target(vault)
        if not target.exists():
            print("No federation auto-tick timer is installed for this vault.")
            return 0
        if shutil.which("launchctl"):
            subprocess.run(["launchctl", "unload", str(target)], capture_output=True)
        target.unlink()
        print(f"Removed the federation auto-tick timer ({target.name}).")
        return 0
    if args.fed_cmd == "cancel":
        _fed_setup(args, need_relay=False)               # sets the state home
        r = outbox.cancel(args.id)
        if r["cancelled"]:
            if r["was"] == "fired":
                print(f"cancelled {args.id[:8]} (was fired) — it won't be delivered, unless a "
                      "tick is depositing it at this very moment.")
            else:
                print(f"cancelled {args.id[:8]} (was staged) — it was never sent.")
            return 0
        if r["reason"] == "not-found":
            print(f"federation: no push {args.id[:8]}.", file=sys.stderr)
        elif r["reason"] == "too-late":
            print(f"federation: push {args.id[:8]} is already `{r['state']}` — it's in the "
                  "peer's mailbox and can't be recalled. Push a correction instead.",
                  file=sys.stderr)
        else:  # raced
            print(f"federation: push {args.id[:8]} was deposited just now "
                  f"(state: {r.get('state')}) — too late to cancel.", file=sys.stderr)
        return 1
    if args.fed_cmd == "review":
        from alexandria_vault.federation import review as fed_review
        vault, config, _ = _fed_setup(args, need_relay=False)
        if args.review_cmd == "list":
            items = fed_review.list_items(vault)
            if not items:
                print("Nothing awaiting review.")
                return 0
            print(f"{len(items)} item(s) awaiting review — nothing is filed until you act:\n")
            for it in items:
                print(f"  {it['id']}  {it['label']:<22} from {it.get('peer') or '?'}"
                      f"  · held: {it.get('reason') or '—'}")
                if it.get("capture"):
                    print(f"      captured: {it['capture']}")
                if it.get("preview"):
                    print(f"      {it['preview']}")
            print("\nFile it yourself: route the capture from Agent/inbox/ where it belongs, then "
                  "delete the item (the next tick sends the receipt).\n"
                  "Approve as-is:  alexandria federation review approve <id>   "
                  "Decline:  alexandria federation review reject <id>")
            return 0
        target = next((it for it in fed_review.list_items(vault) if it.get("id") == args.id), None)
        extra = [target["capture"]] if target and target.get("capture") else []
        commit = lambda m: _git_commit_vault(vault, m, extra=extra)   # noqa: E731
        if args.review_cmd == "approve":
            r = fed_review.approve(vault, config, args.id, commit=commit)
            if r["status"] == "approved":
                print(f"Approved {r['id']} → filed to {r.get('path')} "
                      f"(from {r.get('peer') or 'a peer'})."
                      + (" Receipt queued — goes out on the next tick." if r.get("receipt") else ""))
                return 0
            if r["status"] == "not-found":
                print(f"federation: no parked item {args.id} (see `review list`).",
                      file=sys.stderr)
            elif r["status"] == "no-peer":
                print(f"federation: item {args.id} is from a peer no longer in your config "
                      "— reject it, or re-pair.", file=sys.stderr)
            else:  # blocked
                print(f"federation: can't file {args.id} — {r.get('reason')}. It stays "
                      "parked.", file=sys.stderr)
            return 1
        if args.review_cmd == "reject":
            r = fed_review.reject(vault, args.id, commit=commit)
            if r["status"] == "rejected":
                print(f"Rejected {r['id']} ({r.get('label')}) — dropped (git keeps the "
                      "history)."
                      + (" Declined receipt queued — goes out on the next tick." if r.get("receipt") else ""))
                return 0
            print(f"federation: no parked item {args.id} (see `review list`).",
                  file=sys.stderr)
            return 1
        return 2
    # tick
    vault, config, client = _fed_setup(args)
    # Announce first (docs/RELAY-CENSUS.md §4.1) so a brain with no served process still
    # lands in the census; not forced, so a 5-minute cron tick sends at most once a day.
    from alexandria_vault.federation import birth
    _announce_birth(config, channel=birth.default_channel(), force=False, quiet=True,
                    relay_client=client)
    try:
        summary = worker.tick(vault, config, client)
    except rc.RelayUnavailable as e:
        print(f"federation: relay unreachable — {e}", file=sys.stderr)
        return 1
    # Commit the federation-touched paths whenever inbound WROTE the vault — applied,
    # captured (an inbox item + the ledger must be committed, or a capture is lost on an
    # ephemeral runner), or settled (retired items + receipts).
    wrote = [r for r in summary["inbound"]
             if (r.get("status") in ("applied", "received") and (r.get("applied") or r.get("parked")))
             or r.get("status") == "reconciled"]
    if wrote:
        n = sum(len(r.get("applied") or []) + len(r.get("parked") or [])
                + len(r.get("settled") or []) for r in wrote)
        who = ", ".join(sorted({r.get("from") or "peer" for r in wrote if r.get("from")}))
        paths = [p for r in wrote for p in (r.get("paths") or [])]
        _git_commit_vault(vault, f"federation: {n} item(s)" + (f" from {who}" if who else ""),
                          extra=paths)
    # Break inbound into content vs protocol — a receipt or ping is plumbing, not a
    # message the owner "received". Reporting a bare inbound=3 reads as three letters
    # when it was two acks and a ping (the field-report "3 inbound" confusion).
    inbound = summary["inbound"]
    n_content = sum(1 for r in inbound if r.get("status") in ("applied", "received"))
    n_captured = sum(len(r.get("parked") or []) for r in inbound if r.get("status") == "received")
    n_settled = sum(len(r.get("settled") or []) for r in inbound if r.get("status") == "reconciled")
    n_receipt = sum(1 for r in inbound if str(r.get("status", "")).startswith("receipt"))
    n_ping = sum(1 for r in inbound if r.get("status") == "ping")
    n_other = len(inbound) - n_content - n_receipt - n_ping   # deferred/duplicate/rejected/error
    extra = f" other={n_other}" if n_other else ""
    print(f"deposited={summary['deposited']} rejected={summary['rejected']} "
          f"failed={summary['deposit_failed']} "
          f"inbound={len(inbound)} (content={n_content} receipts={n_receipt} "
          f"pings={n_ping}{extra}) expired={summary['expired']}")
    if n_captured:
        print(f"captured={n_captured} — held in Agent/inbox/ for your review (nothing filed): "
              "`alexandria federation review list`, or drain the inbox.")
    if n_settled:
        print(f"settled={n_settled} — receipts queued for item(s) you filed or declined.")
    return 0


# The federation outbox — where a packaged push note lives (vault-relative). A push
# from here gets its id/state stamped back into the package's frontmatter; the corpus
# policy (vault_core.ignore) keeps the folder out of search/graph/context.
OUTBOX_REL = "Agent/Federation/outbox/"


def _strip_frontmatter(text):
    """Drop a leading YAML frontmatter block — a package's metadata (peer, push id,
    tags) is the sender's bookkeeping, not content for the peer's brain."""
    if text.startswith("---\n") or text.startswith("---\r\n"):
        end = re.search(r"^---[ \t]*$", text[3:], flags=re.M)
        if end:
            return text[3 + end.end():].lstrip("\r\n")
    return text


def _stamp_package(target, vault, handle, pid, state):
    """Write the push id/state into an OUTBOX package's frontmatter (only there —
    a content note pushed by --file is never touched). Best-effort: a stamp that
    fails must not turn a delivered push into an error."""
    from datetime import datetime
    try:
        rel = str(target.relative_to(vault)).replace(os.sep, "/")
    except ValueError:
        return False
    if not rel.startswith(OUTBOX_REL):
        return False
    stamp = {"peer": handle, "push_id": pid[:8],
             "pushed": datetime.now().astimezone().isoformat(timespec="minutes"),
             "push_state": state}
    try:
        text = target.read_text(encoding="utf-8")
        m = re.match(r"^---[ \t]*\r?\n(.*?)\r?\n---[ \t]*\r?\n", text, flags=re.S)
        if m:
            fm = m.group(1).split("\n")
            for k, v in stamp.items():
                line = f"{k}: {v}"
                idx = next((i for i, l in enumerate(fm) if re.match(rf"^{k}\s*:", l)), None)
                if idx is None:
                    fm.append(line)
                else:
                    fm[idx] = line
            text = "---\n" + "\n".join(fm) + "\n---\n" + text[m.end():]
        else:
            text = "---\n" + "\n".join(f"{k}: {v}" for k, v in stamp.items()) + "\n---\n\n" + text
        target.write_text(text, encoding="utf-8")
        return True
    except OSError:
        return False


def _build_intent(args):
    """Turn the push flags into one intent dict (the fixed schema the applier files)."""
    from pathlib import Path
    kind = args.as_kind or ("knowledge" if args.file else "note")
    if args.file:
        if kind not in ("knowledge", "note"):
            raise SystemExit("push: --file is for --as knowledge or --as note.")
        vault = Path(args.vault or ".").expanduser().resolve()
        target = (vault / args.file).resolve()
        # --file is vault-relative: refuse an absolute or ../ path that escapes.
        if target != vault and vault not in target.parents:
            raise SystemExit("push: --file must be a vault-relative path inside the vault.")
        if not target.is_file():
            raise SystemExit(f"push: file not found: {args.file}")
        if kind == "note":
            # A PACKAGED note: the file's body travels as one note intent that lands
            # as its own note (`Areas/<area>/<title>.md`) on the peer's side. The
            # package's frontmatter is the sender's bookkeeping and is stripped.
            if not args.area:
                raise SystemExit("push: --as note needs --area.")
            return {"type": "note", "area": args.area, "title": args.subject or target.stem,
                    "text": _strip_frontmatter(target.read_text(encoding="utf-8"))}
        return {"type": "knowledge", "subject": args.subject or target.stem,
                "body": target.read_text(encoding="utf-8")}
    text = args.text
    if not text:
        raise SystemExit("push: give --text \"…\" or --file <vault-relative.md>.")
    if kind == "todo":
        it = {"type": "todo", "text": text, "area": args.area}
        if args.due:
            it["due"] = args.due
        if not args.area:
            raise SystemExit("push: --as todo needs --area.")
        return it
    if kind == "note":
        if not args.area:
            raise SystemExit("push: --as note needs --area.")
        return {"type": "note", "text": text, "area": args.area}
    if kind == "knowledge":
        if not args.subject:
            raise SystemExit("push: --as knowledge (with --text) needs --subject.")
        return {"type": "knowledge", "subject": args.subject, "body": text}
    if kind == "event":
        it = {"type": "event", "text": text}
        if args.date:
            it["date"] = args.date
        return it
    raise SystemExit(f"push: unknown --as {kind!r}.")


def cmd_push(args) -> int:
    """Stage + fire one intent to a peer in a single command — the CLI invocation IS
    the owner's go (§6 fire rule)."""
    try:
        from alexandria_vault.federation import outbox, worker
        from alexandria_vault.federation import relay_client as rc
    except Exception as e:  # noqa: BLE001
        print(f"push: federation unavailable — {e}", file=sys.stderr)
        return 2
    vault, config, client = _fed_setup(args)
    peer = next((p for p in (config.get("peers") or [])
                 if p.get("handle") == args.handle), None)
    if not peer or not peer.get("fingerprint"):
        print(f"push: no v2 peer @{args.handle} (see `alexandria peer list`).",
              file=sys.stderr)
        return 1
    if peer.get("can_send") is False:
        print(f"push: peer @{args.handle} is marked can_send:false.", file=sys.stderr)
        return 1
    try:
        intent = _build_intent(args)
    except SystemExit as e:
        print(e, file=sys.stderr)
        return 2
    pid = outbox.stage(peer["fingerprint"], "intents", {"intents": [intent]})
    outbox.fire(pid)
    delivered = "staged+fired"
    try:
        worker.deposit_once(vault, config, client)       # deposit only — never receive/apply
        delivered = outbox.get(pid)["state"]
    except rc.RelayUnavailable:
        delivered = "queued (relay unreachable — will deliver on the next tick)"
    where = f" — lands as {intent['area']}/{intent['title']}.md" if intent.get("title") else ""
    print(f"pushed {intent['type']} to @{args.handle} (id {pid[:8]}) — {delivered}{where}")
    if args.file:
        from pathlib import Path
        vroot = Path(args.vault or ".").expanduser().resolve()
        if _stamp_package((vroot / args.file).resolve(), vroot, args.handle, pid, delivered):
            print(f"stamped {args.file} (push_id {pid[:8]}, push_state {delivered})")
    return 0


# The paths the federation applier + review note ever write (§5.5). The review queue is
# named by FILE, not its folder: `Agent/Federation/` also holds the owner's outbox of
# packaged push notes, which a receive commit must never sweep up (a half-written
# package is the owner's work in progress, not something a peer's envelope touched).
_FED_VAULT_PATHS = ["Areas", "Events", "Knowledge", "Agent/Federation Review.md",
                    "Agent/Federation/review-queue.json"]


def _git_commit_vault(vault, message, extra=()):
    """Commit ONLY the federation-touched paths (not `git add -A`, which would fold
    unrelated owner edits into a 'federation:' commit — §5.6). ``extra`` names the
    exact inbox captures a pass wrote or retired (explicit files, never `Agent/inbox`
    as a directory — the owner's other captures are not this commit's). Best-effort."""
    import subprocess
    from pathlib import Path
    present = [p for p in _FED_VAULT_PATHS if (Path(vault) / p).exists()]
    present += [p for p in dict.fromkeys(extra) if p not in present]   # deleted paths stage too
    if not present:
        return
    try:
        subprocess.run(["git", "-C", str(vault), "add", "--", *present],
                       check=False, capture_output=True)
        subprocess.run(["git", "-C", str(vault), "commit", "-m", message, "--", *present],
                       check=False, capture_output=True)
    except Exception:  # noqa: BLE001 — a non-git vault or no changes: nothing to do
        pass


def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(prog="alexandria", description="Alexandria vault software.")
    p.add_argument("--version", action="version", version=f"alexandria {__version__}")
    sub = p.add_subparsers(dest="command", required=True)

    sp = sub.add_parser("init", help="Scaffold a new Alexandria vault.")
    sp.add_argument("--vault", required=True, help="Path to the target vault folder.")
    sp.add_argument("--config", help="Path to a config.json (see config.example.json).")
    sp.add_argument("--answers", help="JSON list of answers for the inline config wizard "
                                      "(used when no --config is given).")
    sp.add_argument("--stamp", help="ISO timestamp to record in render.lock (tests).")
    sp.set_defaults(func=cmd_init)

    sp = sub.add_parser("interview",
                        help="Start the owner's personalization — opens the vault to do the setup interview.")
    sp.add_argument("--vault", help="Path to the vault (default: current directory).")
    sp.add_argument("--no-open", action="store_true",
                    help="Don't auto-open Claude Code; just print how to start.")
    sp.set_defaults(func=cmd_interview)

    sp = sub.add_parser(
        "update",
        help="Refresh Core from the installed software (Config + Content untouched). "
             "New software arrives via --from-zip (folder brains) or a new image (hosted).")
    sp.add_argument("--vault", required=True)
    sp.add_argument("--no-pull", action="store_true",
                    help="Accepted for compatibility — update no longer pulls; this only "
                         "suppresses the zip-channel note on folder brains.")
    sp.add_argument("--from-zip", dest="from_zip", metavar="ZIP_OR_DIR",
                    help="Update a folder-brain's software from an Alexandria.zip (or an "
                         "unzipped copy): verify it's newer, swap .alexandria/app aside with "
                         "rollback, rebuild its venv, re-render Core via the new code, run "
                         "doctor, refresh the router — then you commit. The previous "
                         "software is kept at .alexandria/app.prev until the next update.")
    sp.add_argument("--rollback", action="store_true",
                    help="Swap the kept previous software (.alexandria/app.prev) back into "
                         "place and re-render Core with it. A rollback IS a downgrade — "
                         "the version gate deliberately does not apply.")
    sp.add_argument("--allow-unsigned", dest="allow_unsigned", action="store_true",
                    help="Install a --from-zip source without verifying its release "
                         "signature. Escape hatch only — normally a signed release is "
                         "required once release signing is active.")
    sp.add_argument("--reinstall", action="store_true",
                    help="Force the software swap even when the --from-zip source is the "
                         "SAME version already installed (normally that just re-renders). Use "
                         "it to adopt the official signed bytes of a version you're on a "
                         "test/beta build of.")
    sp.add_argument("--auto", action="store_true",
                    help="Keep current on your own: a rate-limited (daily) check that fetches "
                         "the latest signed release, verifies it, and applies it only if it is "
                         "genuinely newer and the vault tree is clean. Silent when there is "
                         "nothing to do; safe to run every session. Off via alexandria.auto_update.")
    sp.add_argument("--force", action="store_true",
                    help="With --auto, skip the daily rate-limit and check now.")
    sp.add_argument("--stamp", help="ISO timestamp to record in render.lock (tests).")
    sp.add_argument("--safe", action="store_true",
                    help="Hosted-brain mode: snapshot off-host, render Core, health-check, "
                         "and roll the vault back to its pre-update commit if anything fails.")
    sp.set_defaults(func=cmd_update)

    sp = sub.add_parser("vault", help="Off-host durability: backup/restore the vault.")
    sp.add_argument("vault_cmd", choices=["backup", "restore", "stored-head"])
    sp.add_argument("--vault", help="Vault path (default: cwd).")
    sp.add_argument("--config", help="Config JSON for restore onto an empty target.")
    sp.set_defaults(func=cmd_vault)

    sp = sub.add_parser("features",
                        help="Opt-in brain capabilities (digests, email, research).")
    sp.add_argument("features_cmd", choices=["list", "apply"])
    sp.add_argument("--vault", help="Vault to apply to (required for `apply`).")
    sp.add_argument("--features", help="Comma-separated feature names.")
    sp.set_defaults(func=cmd_features)

    sp = sub.add_parser("status", help="Show installed version + module drift.")
    sp.add_argument("--vault", required=True)
    sp.set_defaults(func=cmd_status)

    sp = sub.add_parser("verify-release",
                        help="Check a downloaded Alexandria release zip's Ed25519 signature "
                             "against the pinned Keystone key.")
    sp.add_argument("zip", help="Path to the release .zip to verify.")
    sp.add_argument("--sig", help="Path to the .sig (default: <zip>.sig).")
    sp.set_defaults(func=cmd_verify_release)

    sp = sub.add_parser("doctor", help="Validate config, modules, secrets, travel schedule.")
    sp.add_argument("--vault", required=True)
    sp.add_argument("--quiet", action="store_true",
                    help="Gate mode (used by the update pipeline): one OK line on "
                         "success — WARN/ERROR lines still shown; full output on failure.")
    sp.set_defaults(func=cmd_doctor)

    sp = sub.add_parser("secrets", help="Show required secrets, or --set them interactively.")
    sp.add_argument("--vault", required=True)
    sp.add_argument("--set", action="store_true",
                    help="Interactively enter secret VALUES (hidden) and store them securely — no editor.")
    sp.set_defaults(func=cmd_secrets)

    sp = sub.add_parser("enable", help="Enable a module.")
    sp.add_argument("--vault", required=True)
    sp.add_argument("module")
    sp.add_argument("--stamp", help="ISO timestamp for render.lock (tests).")
    sp.set_defaults(func=cmd_enable)

    sp = sub.add_parser("disable", help="Disable a module.")
    sp.add_argument("--vault", required=True)
    sp.add_argument("module")
    sp.add_argument("--stamp", help="ISO timestamp for render.lock (tests).")
    sp.set_defaults(func=cmd_disable)

    sp = sub.add_parser("seed-area",
                        help="Create one Area's scaffold in an existing vault (the settings "
                             "first-run uses this to build the owner's personal Area).")
    sp.add_argument("--vault", required=True)
    sp.add_argument("--name", required=True, help="The Area name (e.g. the owner's full name).")
    sp.set_defaults(func=cmd_seed_area)

    sp = sub.add_parser("import",
                        help="Bulk-import a directory of documents into the vault "
                             "(PDF/docx/pptx/md/csv/html/eml).")
    sp.add_argument("source", help="Directory to import, walked recursively.")
    sp.add_argument("--vault", required=True)
    sp.add_argument("--plan", action="store_true",
                    help="Dry run: report what WOULD be filed or staged, spending "
                         "no model tokens and writing nothing.")
    sp.add_argument("--instructions", default=None,
                    help="Routing guidance applied to every item, e.g. "
                         "'this is the Acme due-diligence pack'.")
    sp.add_argument("--limit", type=int, default=None,
                    help="Stop after N items — sample a large corpus first.")
    sp.add_argument("--json", action="store_true", help="Print the full report as JSON.")
    sp.add_argument("--quiet", action="store_true", help="Suppress per-item progress.")
    sp.set_defaults(func=cmd_import)

    sp = sub.add_parser("install-launchd",
                        help="Install + load the server-macos launchd jobs (run on the host).")
    sp.add_argument("--vault", required=True, help="Path to the rendered server-macos vault.")
    sp.add_argument("--dry-run", action="store_true",
                    help="Print what would be installed; write nothing, load nothing.")
    sp.add_argument("--no-load", action="store_true",
                    help="Write the plists to ~/Library/LaunchAgents but don't launchctl load them.")
    sp.set_defaults(func=cmd_install_launchd)

    sp = sub.add_parser("harden",
                        help="Dedicated-appliance host hardening: never-sleep + restart-after-power-loss. Run with sudo.")
    sp.set_defaults(func=cmd_harden)

    sp = sub.add_parser("banner", help="Print the Alexandria launch screen.")
    sp.add_argument("--vault", help="Personalize from a vault's config + skills.")
    sp.set_defaults(func=cmd_banner)

    sp = sub.add_parser("serve", help="Run the brain layer as a server — the vault-mcp "
                                      "daemon (streamable HTTP + Bearer seats).")
    sp.add_argument("--vault", help="Vault root (else $VAULT_DIR / walk-up).")
    sp.add_argument("--host", help="Bind address (default 127.0.0.1).")
    sp.add_argument("--port", type=int, help="Port (default config vault_mcp.port, else 8765; "
                                             "0 = ephemeral, announced on the ready line).")
    sp.add_argument("--no-tailscale", action="store_true",
                    help="Skip the Tailscale Serve wiring for this run.")
    sp.add_argument("--no-sync", action="store_true",
                    help="Don't own git (no heartbeat/interval sync) — debugging only.")
    sp.add_argument("--exit-with-parent", action="store_true",
                    help="Exit on stdin EOF — for a spawning desktop sidecar that "
                         "holds the pipe open (not for launchd).")
    sp.set_defaults(func=cmd_serve)

    sp = sub.add_parser("tool", help="Call the brain layer's tool surface from the shell "
                                     "— the same registry vault-mcp serves, no server needed.")
    tsub = sp.add_subparsers(dest="tool_cmd", required=True)
    tp = tsub.add_parser("list", help="List the tools this seat can call.")
    tp.add_argument("--vault", help="Vault root (else $VAULT_DIR / walk-up).")
    tp.add_argument("--json", action="store_true", help="Full JSON (names, descriptions, schemas).")
    tp.add_argument("--read-only", action="store_true", help="Act as a read-only seat.")
    tp = tsub.add_parser("schema", help="Print one tool's description + input schema.")
    tp.add_argument("name", help="Tool name (see `alexandria tool list`).")
    tp.add_argument("--vault", help="Vault root (else $VAULT_DIR / walk-up).")
    tp.add_argument("--read-only", action="store_true", help="Act as a read-only seat.")
    tp = tsub.add_parser("call", help="Call one tool; prints the JSON result.")
    tp.add_argument("name", help="Tool name (see `alexandria tool list`).")
    tp.add_argument("--args", help='Tool arguments as a JSON object, e.g. \'{"path": "Home.md"}\'.')
    tp.add_argument("--vault", help="Vault root (else $VAULT_DIR / walk-up).")
    tp.add_argument("--read-only", action="store_true", help="Act as a read-only seat.")
    for _tp in tsub.choices.values():
        _tp.set_defaults(func=cmd_tool)

    # --- V4 federation: peer pairing + transport ------------------------------
    # Pairing is a resumable, non-blocking sequence so Alexandria can walk an owner
    # through it (invite → check → confirm, or add → confirm) — no command blocks, and
    # the verification-word check is an explicit `confirm`/`abort`, never a y/n prompt.
    sp = sub.add_parser("peer", help="Pair with and manage peer vaults (federation).")
    psub = sp.add_subparsers(dest="peer_cmd", required=True)
    pp = psub.add_parser("invite", help="Start a pairing: mint a code to share with a peer "
                                        "(non-blocking; then `peer check`).")
    pp.add_argument("name", help="What to call this peer in your vault.")
    pp.add_argument("--handle", help="Short handle (default: derived from the name).")
    pp.add_argument("--vault", help="Vault root (default: cwd).")
    pp = psub.add_parser("check", help="Check whether an invited peer has answered yet; "
                                       "shows the verification words when they have.")
    pp.add_argument("handle", help="The handle you invited (see `peer pending`).")
    pp.add_argument("--vault", help="Vault root (default: cwd).")
    pp = psub.add_parser("add", help="Redeem a peer's five-word code, showing the "
                                     "verification words (non-blocking; then `peer confirm`).")
    pp.add_argument("code", help="The five-word code the peer shared.")
    pp.add_argument("--handle", help="Your short handle for this peer "
                                     "(default: the handle they chose for themselves).")
    pp.add_argument("--name", help="What to call this peer in your vault "
                                   "(default: the name they chose for themselves).")
    pp.add_argument("--vault", help="Vault root (default: cwd).")
    pp = psub.add_parser("confirm", help="Finalize a pairing after the verification words "
                                         "matched on both sides.")
    pp.add_argument("handle", help="The peer's handle (see `peer pending`).")
    pp.add_argument("--vault", help="Vault root (default: cwd).")
    pp = psub.add_parser("abort", help="Reject a pairing whose words did NOT match, revoking "
                                       "the token it exposed.")
    pp.add_argument("handle", help="The peer's handle (see `peer pending`).")
    pp.add_argument("--vault", help="Vault root (default: cwd).")
    pp = psub.add_parser("pending", help="Show pairings in progress + their next step.")
    pp.add_argument("--vault", help="Vault root (default: cwd).")
    pp = psub.add_parser("list", help="List paired peers (handle, name, fingerprint, "
                         "linked People note).")
    pp.add_argument("--vault", help="Vault root (default: cwd).")
    pp = psub.add_parser("verify", help="Show a peer's three verification words on demand "
                         "(re-check their identity out of band).")
    pp.add_argument("handle", help="The peer's handle.")
    pp.add_argument("--vault", help="Vault root (default: cwd).")
    pp = psub.add_parser("inbox", help="Peek at the relay mailbox: what's waiting, "
                         "decrypted and summarized (read-only — nothing is consumed).")
    pp.add_argument("--vault", help="Vault root (default: cwd).")
    pp = psub.add_parser("remove", help="Remove a peer (by handle) + its stored secret.")
    pp.add_argument("handle", help="The peer's handle.")
    pp.add_argument("--vault", help="Vault root (default: cwd).")
    for _pp in psub.choices.values():
        _pp.set_defaults(func=cmd_peer)

    sp = sub.add_parser("push", help="Send one intent to a peer (stage + fire).")
    sp.add_argument("handle", help="The peer's handle (see `alexandria peer list`).")
    sp.add_argument("--text", help="The intent text (for note/todo/knowledge/event).")
    sp.add_argument("--file", help="A vault-relative .md file to send: as knowledge (the "
                    "whole file), or with --as note --area a PACKAGED note that lands as its "
                    "own note on their side (frontmatter stripped; a package written in "
                    "Agent/Federation/outbox/ gets the push id stamped back).")
    sp.add_argument("--as", dest="as_kind",
                    choices=["note", "todo", "knowledge", "event"],
                    help="Intent type (default: note, or knowledge with --file).")
    sp.add_argument("--area", help="Target area (required for note/todo).")
    sp.add_argument("--subject", help="Knowledge subject, or the packaged note's title "
                    "(default: the file stem).")
    sp.add_argument("--due", help="Due date (todo).")
    sp.add_argument("--date", help="Event date (event).")
    sp.add_argument("--vault", help="Vault root (default: cwd).")
    sp.set_defaults(func=cmd_push)

    sp = sub.add_parser("federation", help="Run one federation pass, or show status.")
    fsub = sp.add_subparsers(dest="fed_cmd", required=True)
    fp = fsub.add_parser("fire", help="Fire a staged push by id (staged -> fired).")
    fp.add_argument("id", help="The push id from peer_stage / peer list.")
    fp.add_argument("--vault", help="Vault root (default: cwd).")
    fp = fsub.add_parser("tick", help="Deposit fired pushes + receive inbound once.")
    fp.add_argument("--vault", help="Vault root (default: cwd).")
    fp = fsub.add_parser("status", help="Show mailbox + outbox + per-peer push status.")
    fp.add_argument("--vault", help="Vault root (default: cwd).")
    fp = fsub.add_parser("cancel", help="Cancel a staged/fired push by id (before it's "
                         "deposited — a delivered push can't be recalled).")
    fp.add_argument("id", help="The push id from peer_stage / peer list.")
    fp.add_argument("--vault", help="Vault root (default: cwd).")
    fp = fsub.add_parser("install-timer", help="Install a launchd timer so this (local) brain "
                         "receives on its own, without a manual tick (macOS).")
    fp.add_argument("--interval", type=int,
                    help="Seconds between ticks (default: max(300, poll_seconds)).")
    fp.add_argument("--vault", help="Vault root (default: cwd).")
    fp = fsub.add_parser("uninstall-timer", help="Remove the federation auto-tick launchd timer.")
    fp.add_argument("--vault", help="Vault root (default: cwd).")
    fp = fsub.add_parser("review", help="Review parked inbound items: list | approve <id> "
                         "| reject <id>.")
    rsub = fp.add_subparsers(dest="review_cmd", required=True)
    rp = rsub.add_parser("list", help="List items awaiting your review.")
    rp.add_argument("--vault", help="Vault root (default: cwd).")
    rp = rsub.add_parser("approve", help="File a parked item by id (owner override).")
    rp.add_argument("id", help="The item id from `review list`.")
    rp.add_argument("--vault", help="Vault root (default: cwd).")
    rp = rsub.add_parser("reject", help="Drop a parked item by id (git keeps history).")
    rp.add_argument("id", help="The item id from `review list`.")
    rp.add_argument("--vault", help="Vault root (default: cwd).")
    for _rp in rsub.choices.values():
        _rp.set_defaults(func=cmd_federation)
    for _fp in fsub.choices.values():
        _fp.set_defaults(func=cmd_federation)

    return p


def main(argv=None) -> int:
    argv = argv if argv is not None else sys.argv[1:]
    args = build_parser().parse_args(argv)
    return args.func(args)


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