#!/usr/bin/env python3
"""synthesis-onboarding engine.

Converges a machine toward a working synthesis ecosystem install:

  install         (default) converge everything; safe to re-run anytime
  update          alias of install
  doctor          verify-only; exit 0 healthy / 1 defects / 2 cannot verify
  init-workspace  scaffold ~/workspaces/<name>/ai-knowledge-<name>/
  uninstall       archive-then-remove engine-generated files

Public mode installs the synthesis-skills plugin (Claude Code and/or Codex)
and can scaffold a personal knowledge workspace. Org mode adds an
organization's knowledge bases and shared skills, declared in the org's
`.agents/onboarding.yaml` manifest — organizations ship configuration, not
installer code. Schema: references/org-manifest.md beside this skill.

Design rules: python3 stdlib only (PyYAML used when present); idempotent
convergence with receipts at ~/.synthesis/onboarding/receipts.json; conffile
semantics (never clobber user-edited files); archive before every removal;
fail closed and loud on stale caches and unknown manifest keys.
"""

import argparse
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path

ENGINE_VERSION = "1.0.1"
PUBLIC_REPO_HTTPS = "https://github.com/synthesisengineering/synthesis-skills.git"
PUBLIC_MARKETPLACE_REF = "synthesisengineering/synthesis-skills"
PLUGIN_NAME = "synthesis-skills"
MARKETPLACE_NAME = "synthesis-engineering"

HOME = Path(os.environ.get("SYNTHESIS_ONBOARD_HOME", str(Path.home())))
STATE_DIR = Path(os.environ.get("SYNTHESIS_ONBOARD_STATE_DIR", str(HOME / ".synthesis" / "onboarding")))
WORKSPACES_ROOT = Path(os.environ.get("SYNTHESIS_WORKSPACES_ROOT", str(HOME / "workspaces")))
CACHE_DIR = Path(os.environ.get("SYNTHESIS_ONBOARD_CACHE_DIR", os.environ.get("XDG_CACHE_HOME", str(HOME / ".cache")))) / "synthesis-onboarding"
RECEIPTS_PATH = STATE_DIR / "receipts.json"
ALLOW_STALE = os.environ.get("SYNTHESIS_ONBOARD_ALLOW_STALE", "") == "1"

GENERATED_MARK = "generated by synthesis-onboarding"

OK, CHANGED, WOULD, ACTION, WARN, ERROR, SKIP = (
    "ok", "changed", "would-change", "action-needed", "warning", "error", "skipped")
_BADGES = {OK: "  ok", CHANGED: "  ++", WOULD: "  ~~", ACTION: "  !!", WARN: "  ?!", ERROR: "  XX", SKIP: "  --"}


def utcnow():
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def sha256_text(text):
    return hashlib.sha256(text.encode("utf-8")).hexdigest()


def run(cmd, cwd=None, env=None, timeout=300):
    """Run a command; return (rc, stdout, stderr). Never raises on rc!=0."""
    merged = dict(os.environ)
    if env:
        merged.update(env)
    try:
        proc = subprocess.run(
            cmd, cwd=str(cwd) if cwd else None, env=merged, timeout=timeout,
            stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
        return proc.returncode, proc.stdout, proc.stderr
    except FileNotFoundError as exc:
        return 127, "", str(exc)
    except subprocess.TimeoutExpired:
        return 124, "", "timeout after %ss: %s" % (timeout, " ".join(map(str, cmd)))


class Report:
    """Collects step results; renders text or JSON; tracks exit code."""

    def __init__(self, dry_run=False, as_json=False):
        self.steps = []
        self.dry_run = dry_run
        self.as_json = as_json

    def add(self, phase, status, detail, hint=None):
        if self.dry_run and status == CHANGED:
            status = WOULD
        self.steps.append({"phase": phase, "status": status, "detail": detail, "hint": hint})
        if not self.as_json:
            line = "%s [%s] %s" % (_BADGES.get(status, "  ??"), phase, detail)
            print(line)
            if hint:
                for hline in hint.strip().splitlines():
                    print("        %s" % hline)
        return status

    def counts(self):
        out = {}
        for step in self.steps:
            out[step["status"]] = out.get(step["status"], 0) + 1
        return out

    def exit_code(self):
        counts = self.counts()
        if counts.get(ERROR):
            return 1
        if counts.get(ACTION):
            return 1
        return 0


# ---------------------------------------------------------------------------
# Manifest loading: PyYAML when present, else a documented-subset parser.
# ---------------------------------------------------------------------------

def _coerce_scalar(token):
    token = token.strip()
    if token.startswith('"') and token.endswith('"') and len(token) >= 2:
        return token[1:-1]
    if token.startswith("'") and token.endswith("'") and len(token) >= 2:
        return token[1:-1]
    lowered = token.lower()
    if lowered in ("true", "yes"):
        return True
    if lowered in ("false", "no"):
        return False
    if lowered in ("null", "~", ""):
        return None
    if re.fullmatch(r"-?\d+", token):
        return int(token)
    return token


def parse_subset_yaml(text, source="manifest"):
    """Parse the documented YAML subset: nested maps, lists ('- '), scalars,
    literal blocks ('|'), full-line comments. Raises ValueError with line
    numbers on anything outside the subset (fail closed, no guessing)."""
    lines = text.splitlines()
    items = []  # (indent, kind, payload, lineno); kind: kv, key, list_scalar, list_map_start
    index = 0
    while index < len(lines):
        raw = lines[index]
        lineno = index + 1
        stripped = raw.strip()
        index += 1
        if not stripped or stripped.startswith("#"):
            continue
        indent = len(raw) - len(raw.lstrip(" "))
        if raw.lstrip(" ").startswith("- "):
            body = raw.lstrip(" ")[2:]
            key_candidate = body.split(":", 1)[0].strip()
            looks_like_map = (": " in body or body.endswith(":")) and \
                re.fullmatch(r"[A-Za-z0-9_-]+", key_candidate) and not body.startswith(("'", '"'))
            if looks_like_map:
                items.append((indent, "list_map_start", body, lineno))
            else:
                items.append((indent, "list_scalar", body, lineno))
            continue
        if ":" not in stripped:
            raise ValueError("%s:%d: expected 'key: value', got %r" % (source, lineno, stripped))
        key, _, rest = stripped.partition(":")
        rest = rest.strip()
        if rest == "|":
            block = []
            block_indent = None
            while index < len(lines):
                nxt = lines[index]
                if nxt.strip() == "":
                    block.append("")
                    index += 1
                    continue
                nxt_indent = len(nxt) - len(nxt.lstrip(" "))
                if nxt_indent <= indent:
                    break
                if block_indent is None:
                    block_indent = nxt_indent
                block.append(nxt[block_indent:])
                index += 1
            while block and block[-1] == "":
                block.pop()
            items.append((indent, "kv", (key.strip(), "\n".join(block) + "\n"), lineno))
        elif rest == "":
            items.append((indent, "key", key.strip(), lineno))
        else:
            items.append((indent, "kv", (key.strip(), _coerce_scalar(rest)), lineno))

    def build(start, end, indent):
        if start >= end:
            return {}
        kind = items[start][1]
        if kind in ("list_scalar", "list_map_start"):
            result = []
            pos = start
            while pos < end:
                item_indent, item_kind, payload, lineno = items[pos]
                if item_indent != indent:
                    raise ValueError("%s:%d: inconsistent list indentation" % (source, lineno))
                if item_kind == "list_scalar":
                    result.append(_coerce_scalar(payload))
                    pos += 1
                    continue
                # list item opening an inline map: '- key: value'
                sub_end = pos + 1
                while sub_end < end and items[sub_end][0] > indent:
                    sub_end += 1
                key, _, rest = payload.partition(":")
                entry = {}
                rest = rest.strip()
                if rest:
                    entry[key.strip()] = _coerce_scalar(rest)
                elif pos + 1 < sub_end:
                    raise ValueError("%s:%d: '- key:' with nested block is outside the subset; use '- key: value' pairs" % (source, lineno))
                child = build(pos + 1, sub_end, items[pos + 1][0]) if pos + 1 < sub_end else {}
                if child and not isinstance(child, dict):
                    raise ValueError("%s:%d: list items must be scalars or flat maps" % (source, lineno))
                entry.update(child or {})
                result.append(entry)
                pos = sub_end
            return result
        result = {}
        pos = start
        while pos < end:
            item_indent, item_kind, payload, lineno = items[pos]
            if item_indent != indent:
                raise ValueError("%s:%d: inconsistent map indentation" % (source, lineno))
            if item_kind == "kv":
                key, value = payload
                if key in result:
                    raise ValueError("%s:%d: duplicate key %r" % (source, lineno, key))
                result[key] = value
                pos += 1
            elif item_kind == "key":
                sub_end = pos + 1
                while sub_end < end and items[sub_end][0] > indent:
                    sub_end += 1
                if sub_end == pos + 1:
                    result[payload] = None
                else:
                    result[payload] = build(pos + 1, sub_end, items[pos + 1][0])
                pos = sub_end
            else:
                raise ValueError("%s:%d: list item where a key was expected" % (source, lineno))
        return result

    return build(0, len(items), items[0][0] if items else 0)


TOP_KEYS = {"version", "org", "ecosystem", "skills_repos", "knowledge_bases",
            "auth_help", "welcome", "migrations", "workspace_instructions"}
ORG_KEYS = {"id", "name", "workspace"}
SKILLS_REPO_KEYS = {"name", "primary", "fallbacks", "installer", "installer_args",
                    "source_env", "status_args"}
KB_KEYS = {"name", "primary", "superseded_remotes", "local_hooks", "default_branch"}
WELCOME_KEYS = {"title", "try_asking", "docs"}
MIGRATION_KEYS = {"from", "action", "to", "note"}


def _require(cond, message):
    if not cond:
        raise ValueError("manifest: %s" % message)


def validate_manifest(data, path):
    _require(isinstance(data, dict), "top level must be a mapping")
    unknown = set(data) - TOP_KEYS
    _require(not unknown, "unknown top-level keys: %s (fail-closed; see org-manifest.md)" % ", ".join(sorted(unknown)))
    _require(data.get("version") == 1, "version must be 1")
    org = data.get("org") or {}
    _require(isinstance(org, dict) and not set(org) - ORG_KEYS, "org block has unknown keys")
    _require(org.get("id") and org.get("workspace"), "org.id and org.workspace are required")
    for repo in data.get("skills_repos") or []:
        _require(isinstance(repo, dict) and not set(repo) - SKILLS_REPO_KEYS,
                 "skills_repos entry has unknown keys: %r" % repo)
        _require(repo.get("name") and repo.get("primary"), "skills_repos entries need name + primary")
    for kb in data.get("knowledge_bases") or []:
        _require(isinstance(kb, dict) and not set(kb) - KB_KEYS,
                 "knowledge_bases entry has unknown keys: %r" % kb)
        _require(kb.get("name") and kb.get("primary"), "knowledge_bases entries need name + primary")
    welcome = data.get("welcome") or {}
    _require(isinstance(welcome, dict) and not set(welcome) - WELCOME_KEYS, "welcome block has unknown keys")
    migrations = data.get("migrations") or {}
    _require(isinstance(migrations, dict) and not set(migrations) - {"skills"}, "migrations supports only 'skills'")
    for mig in migrations.get("skills") or []:
        _require(isinstance(mig, dict) and not set(mig) - MIGRATION_KEYS, "migration entry has unknown keys")
        _require(mig.get("from") and mig.get("action") in ("remove", "rename"),
                 "migrations.skills entries need from + action (remove|rename)")
        if mig.get("action") == "rename":
            _require(mig.get("to"), "rename migrations need 'to'")
    data.setdefault("_path", str(path))
    return data


def load_manifest(path):
    text = Path(path).read_text(encoding="utf-8")
    try:
        import yaml  # type: ignore
        data = yaml.safe_load(text)
    except ImportError:
        data = parse_subset_yaml(text, source=str(path))
    return validate_manifest(data, path)


# ---------------------------------------------------------------------------
# Receipts (conffile + uninstall + adoption memory)
# ---------------------------------------------------------------------------

class Receipts:
    def __init__(self, path=RECEIPTS_PATH):
        self.path = Path(path)
        if self.path.exists():
            self.data = json.loads(self.path.read_text(encoding="utf-8"))
        else:
            self.data = {"version": 1, "generated_files": {}, "adopted_repos": {}, "runs": []}

    def save(self):
        self.path.parent.mkdir(parents=True, exist_ok=True)
        tmp = self.path.with_suffix(".tmp")
        tmp.write_text(json.dumps(self.data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
        os.replace(tmp, self.path)

    def file_sha(self, path):
        return (self.data["generated_files"].get(str(path)) or {}).get("sha256")

    def record_file(self, path, content, kind):
        self.data["generated_files"][str(path)] = {
            "sha256": sha256_text(content), "kind": kind, "written_at": utcnow(),
            "engine": ENGINE_VERSION}

    def forget_file(self, path):
        self.data["generated_files"].pop(str(path), None)

    def adopted(self, name):
        return self.data["adopted_repos"].get(name)

    def record_adoption(self, name, path):
        self.data["adopted_repos"][name] = str(path)


def archive_path(target):
    stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    dest = STATE_DIR / "backups" / stamp / target.name
    dest.parent.mkdir(parents=True, exist_ok=True)
    return dest


def ensure_file(report, receipts, path, content, kind, dry_run):
    """Conffile-semantics write. Returns final status."""
    path = Path(path)
    if path.exists():
        current = path.read_text(encoding="utf-8")
        if current == content:
            receipts.record_file(path, content, kind)
            return report.add(kind, OK, "%s is current" % path)
        owned = receipts.file_sha(path) == sha256_text(current)
        if not owned and GENERATED_MARK not in current:
            return report.add(kind, WARN,
                              "%s exists with your own edits — left untouched" % path,
                              hint="Remove the file and re-run to regenerate it.")
        if dry_run:
            return report.add(kind, CHANGED, "would update %s (backup kept)" % path)
        backup = archive_path(path)
        shutil.copy2(path, backup)
        path.write_text(content, encoding="utf-8")
        receipts.record_file(path, content, kind)
        return report.add(kind, CHANGED, "updated %s (previous copy archived)" % path)
    if dry_run:
        return report.add(kind, CHANGED, "would create %s" % path)
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(content, encoding="utf-8")
    receipts.record_file(path, content, kind)
    return report.add(kind, CHANGED, "created %s" % path)


# ---------------------------------------------------------------------------
# Client + plugin handling
# ---------------------------------------------------------------------------

CLIENT_WELL_KNOWN = {
    "claude": [HOME / ".local/bin/claude", Path("/usr/local/bin/claude"),
               Path("/opt/homebrew/bin/claude")],
    "codex": [Path("/Applications/ChatGPT.app/Contents/Resources/codex"),
              Path("/usr/local/bin/codex"), Path("/opt/homebrew/bin/codex")],
}


def resolve_client(name):
    env_key = "SYNTHESIS_%s_BIN" % name.upper()
    if env_key in os.environ:
        value = os.environ[env_key].strip()
        return value or None  # set-but-empty means "treat as absent"
    found = shutil.which(name)
    if found:
        return found
    for candidate in CLIENT_WELL_KNOWN.get(name, []):
        if candidate.exists():
            return str(candidate)
    return None


def plugin_enabled(client, binary):
    rc, out, _ = run([binary, "plugin", "list", "--json"], timeout=60)
    if rc != 0:
        return None  # unknown — CLI unavailable or errored
    try:
        entries = json.loads(out)
    except ValueError:
        return PLUGIN_NAME in out and '"enabled": true' in out
    if isinstance(entries, dict):
        # claude nests under "plugins"; codex nests under "installed"
        flat = []
        for value in entries.values():
            if isinstance(value, list):
                flat.extend(value)
        entries = flat
    for entry in entries or []:
        if not isinstance(entry, dict) or not entry.get("enabled", False):
            continue
        ident = str(entry.get("id") or entry.get("pluginId") or "")
        if ident.startswith(PLUGIN_NAME + "@") or entry.get("name") == PLUGIN_NAME:
            return True
    return False


def install_plugin(client, binary):
    """Best-effort native plugin install. Returns (success, detail)."""
    add_cmd = [binary, "plugin", "marketplace", "add", PUBLIC_MARKETPLACE_REF]
    rc, out, err = run(add_cmd, timeout=180)
    if rc != 0 and "already" not in (out + err).lower():
        return False, "marketplace add failed: %s" % (err.strip() or out.strip())
    verb = "install" if client == "claude" else "add"
    rc, out, err = run([binary, "plugin", verb, "%s@%s" % (PLUGIN_NAME, MARKETPLACE_NAME)], timeout=300)
    if rc != 0 and "already" not in (out + err).lower():
        return False, "plugin %s failed: %s" % (verb, err.strip() or out.strip())
    return True, "installed via %s CLI" % client


def source_root():
    override = os.environ.get("SYNTHESIS_ONBOARD_SOURCE_DIR")
    if override:
        return Path(override)
    return Path(__file__).resolve().parents[3]


# ---------------------------------------------------------------------------
# Git helpers
# ---------------------------------------------------------------------------

def git(args, cwd=None, timeout=300):
    return run(["git"] + args, cwd=cwd, timeout=timeout)


def origin_url(repo):
    rc, out, _ = git(["remote", "get-url", "origin"], cwd=repo)
    return out.strip() if rc == 0 else None


def is_dirty(repo):
    rc, out, _ = git(["status", "--porcelain"], cwd=repo)
    return bool(out.strip()) if rc == 0 else True


def clone_or_update(report, phase, url_list, dest, dry_run, allow_stale=ALLOW_STALE):
    """Ensure dest is a clone of url_list[0] (fallbacks tried on fresh clone).
    Fail LOUD when an existing cache cannot be refreshed (no silent stale)."""
    dest = Path(dest)
    if (dest / ".git").exists():
        rc, _, err = git(["pull", "--ff-only"], cwd=dest, timeout=180)
        if rc == 0:
            return report.add(phase, OK, "%s is current" % dest)
        if allow_stale:
            return report.add(phase, WARN, "%s could not refresh; continuing with cached copy (allow-stale)" % dest,
                              hint=err.strip()[-300:] if err else None)
        return report.add(phase, ERROR, "%s exists but could not refresh — refusing stale content" % dest,
                          hint="Fix network/auth and re-run, or set SYNTHESIS_ONBOARD_ALLOW_STALE=1 to accept the cached copy.\n%s" % (err.strip()[-300:] if err else ""))
    if dry_run:
        return report.add(phase, CHANGED, "would clone %s -> %s" % (url_list[0], dest))
    dest.parent.mkdir(parents=True, exist_ok=True)
    last_err = ""
    for url in url_list:
        rc, _, err = git(["clone", url, str(dest)], timeout=600)
        if rc == 0:
            return report.add(phase, CHANGED, "cloned %s" % url)
        last_err = err.strip()
    return report.add(phase, ERROR, "could not clone %s" % url_list[0], hint=last_err[-400:])


# ---------------------------------------------------------------------------
# Phases
# ---------------------------------------------------------------------------

def phase_preflight(report, clients_wanted):
    if sys.platform != "darwin":
        report.add("preflight", WARN, "v1 targets macOS; this platform (%s) is best-effort" % sys.platform)
    rc, out, _ = run(["git", "--version"], timeout=15)
    if rc != 0:
        report.add("preflight", ERROR, "git is not available",
                   hint="On macOS run:  xcode-select --install\nThen re-run this installer.")
        return None
    report.add("preflight", OK, out.strip())
    clients = {}
    for name in clients_wanted:
        binary = resolve_client(name)
        clients[name] = binary
        if binary:
            report.add("preflight", OK, "%s found: %s" % (name, binary))
        else:
            report.add("preflight", SKIP, "%s not found — skipping that client" % name,
                       hint="Install Claude Code or the ChatGPT app to use the skills there, then re-run.")
    if not any(clients.values()):
        report.add("preflight", WARN, "no AI client detected; repos and workspace will still be set up")
    return clients


def phase_ecosystem(report, clients, dry_run, no_plugin_cli):
    """Public synthesis-skills into each present client; fallback to install.sh."""
    need_fallback = False
    for name, binary in clients.items():
        if not binary:
            continue
        state = plugin_enabled(name, binary)
        if state is True:
            report.add("ecosystem", OK, "%s plugin already enabled (%s)" % (PLUGIN_NAME, name))
            continue
        if no_plugin_cli:
            need_fallback = True
            report.add("ecosystem", WARN, "plugin CLI disabled by flag for %s; using file fallback" % name)
            continue
        if dry_run:
            report.add("ecosystem", CHANGED, "would install %s plugin via %s CLI" % (PLUGIN_NAME, name))
            continue
        success, detail = install_plugin(name, binary)
        if success and plugin_enabled(name, binary):
            report.add("ecosystem", CHANGED, "%s: %s" % (name, detail))
        else:
            need_fallback = True
            report.add("ecosystem", WARN, "%s plugin CLI route failed; using file fallback" % name,
                       hint=detail)
    if need_fallback:
        installer = source_root() / "install.sh"
        if not installer.exists():
            report.add("ecosystem", ERROR, "fallback installer missing at %s" % installer)
            return
        if dry_run:
            report.add("ecosystem", CHANGED, "would run fallback: sh %s install" % installer)
            return
        # A clean `status` alone is not proof of an install: on a machine
        # where the target directories do not exist yet, status has nothing
        # to check and exits 0. Require actual copies before skipping.
        rc, _, _ = run(["sh", str(installer), "status"], timeout=300)
        copies_present = any(
            target.is_dir() and any(target.glob("synthesis-*"))
            for target in (HOME / ".claude" / "skills", HOME / ".agents" / "skills"))
        if rc == 0 and copies_present:
            report.add("ecosystem", OK, "fallback skill copies already current")
            return
        rc, out, err = run(["sh", str(installer), "install"], timeout=600)
        if rc == 0:
            report.add("ecosystem", CHANGED, "fallback skill copies installed (install.sh)")
        else:
            report.add("ecosystem", ERROR, "fallback install.sh failed", hint=(err or out).strip()[-400:])


def phase_org_skills(report, manifest, dry_run):
    for repo in manifest.get("skills_repos") or []:
        cache = CACHE_DIR / repo["name"]
        urls = [repo["primary"]] + list(repo.get("fallbacks") or [])
        status = clone_or_update(report, "org-skills", urls, cache, dry_run)
        if status in (ERROR,):
            continue
        installer_rel = repo.get("installer")
        if not installer_rel:
            continue
        installer = cache / installer_rel
        args = [str(HOME) if a == "$HOME" else a for a in (repo.get("installer_args") or [])]
        if dry_run or status == WOULD:
            report.add("org-skills", CHANGED, "would run %s %s" % (installer, " ".join(args)))
            continue
        if not installer.exists():
            report.add("org-skills", ERROR, "installer %s not found in %s" % (installer_rel, repo["name"]))
            continue
        env = {}
        if repo.get("source_env"):
            env[repo["source_env"]] = str(cache)
        status_args = repo.get("status_args")
        if status_args:
            probe = [str(HOME) if a == "$HOME" else a for a in status_args]
            rc, _, _ = run(["sh", str(installer)] + probe, env=env, timeout=300)
            if rc == 0:
                report.add("org-skills", OK, "%s already installed and clean" % repo["name"])
                continue
        rc, out, err = run(["sh", str(installer), "install"] + args, env=env, timeout=600)
        if rc == 0:
            report.add("org-skills", CHANGED, "%s installer completed" % repo["name"])
        else:
            report.add("org-skills", ERROR, "%s installer failed" % repo["name"],
                       hint=(err or out).strip()[-400:])


def find_adoptable(name, primary, superseded):
    """Search shallow workspace paths for an existing clone of this repo."""
    wanted = {primary} | set(superseded or [])
    candidates = []
    if WORKSPACES_ROOT.exists():
        candidates.extend(WORKSPACES_ROOT.glob("*/%s" % name))
        candidates.extend(WORKSPACES_ROOT.glob(name))
    for candidate in candidates:
        if (candidate / ".git").exists() and origin_url(candidate) in wanted:
            return candidate
    return None


def phase_kbs(report, manifest, receipts, dry_run):
    workspace = manifest["org"]["workspace"]
    auth_help = (manifest.get("auth_help") or "").strip()
    for kb in manifest.get("knowledge_bases") or []:
        name, primary = kb["name"], kb["primary"]
        superseded = kb.get("superseded_remotes") or []
        standard = WORKSPACES_ROOT / workspace / name
        adopted = receipts.adopted(name)
        repo = Path(adopted) if adopted and Path(adopted, ".git").exists() else None
        if repo is None and (standard / ".git").exists():
            repo = standard
        if repo is None:
            found = find_adoptable(name, primary, superseded)
            if found is not None:
                repo = found
                receipts.record_adoption(name, found)
                report.add("knowledge-base", OK, "adopted existing clone of %s at %s" % (name, found))
        if repo is None:
            if dry_run:
                report.add("knowledge-base", CHANGED, "would clone %s -> %s" % (primary, standard))
                continue
            rc, _, err = git(["ls-remote", primary], timeout=60)
            if rc != 0:
                report.add("knowledge-base", ACTION, "cannot reach %s yet — one step needs you" % name,
                           hint=(auth_help or "Set up your git access for %s, then re-run this installer." % primary))
                continue
            standard.parent.mkdir(parents=True, exist_ok=True)
            rc, _, err = git(["clone", primary, str(standard)], timeout=900)
            if rc != 0:
                report.add("knowledge-base", ERROR, "clone of %s failed" % name, hint=err.strip()[-400:])
                continue
            repo = standard
            report.add("knowledge-base", CHANGED, "cloned %s -> %s" % (name, standard))
        current_origin = origin_url(repo)
        if current_origin in superseded:
            if dry_run:
                report.add("knowledge-base", CHANGED, "would repoint %s origin -> %s" % (name, primary))
            else:
                git(["remote", "set-url", "origin", primary], cwd=repo)
                report.add("knowledge-base", CHANGED, "repointed %s origin to the current primary remote" % name)
        elif current_origin != primary:
            report.add("knowledge-base", WARN, "%s origin is %s (expected %s) — left as-is" % (name, current_origin, primary))
        if not dry_run:
            if is_dirty(repo):
                report.add("knowledge-base", WARN, "%s has local changes; skipped pull" % name)
            else:
                rc, _, err = git(["pull", "--ff-only"], cwd=repo, timeout=300)
                if rc == 0:
                    report.add("knowledge-base", OK, "%s is up to date" % name)
                else:
                    report.add("knowledge-base", WARN, "%s could not fast-forward" % name,
                               hint=err.strip()[-300:] if err else None)
        if kb.get("local_hooks") and (Path(repo) / ".githooks").is_dir():
            rc, out, _ = git(["config", "--global", "--get", "core.hooksPath"])
            if rc == 0 and out.strip():
                report.add("knowledge-base", OK, "global git hooks engine already active; not overriding")
            else:
                rc, out, _ = git(["config", "--local", "--get", "core.hooksPath"], cwd=repo)
                if out.strip() == ".githooks":
                    report.add("knowledge-base", OK, "%s pre-commit protection wired" % name)
                elif dry_run:
                    report.add("knowledge-base", CHANGED, "would wire %s repo-local hooks (.githooks)" % name)
                else:
                    git(["config", "--local", "core.hooksPath", ".githooks"], cwd=repo)
                    report.add("knowledge-base", CHANGED, "wired %s repo-local pre-commit protection" % name)


def workspace_agents_md(manifest):
    org = manifest["org"]
    welcome = manifest.get("welcome") or {}
    kbs = manifest.get("knowledge_bases") or []
    lines = []
    lines.append("<!-- %s v%s; re-runs update this file unless you edit it -->" % (GENERATED_MARK, ENGINE_VERSION))
    lines.append("")
    lines.append("# %s Workspace" % (org.get("name") or org["id"]))
    lines.append("")
    lines.append(welcome.get("title") or "Welcome to the %s knowledge workspace." % (org.get("name") or org["id"]))
    lines.append("")
    if kbs:
        lines.append("## Knowledge bases")
        lines.append("")
        for kb in kbs:
            lines.append("- `%s/` — shared knowledge base. Read freely; all edits ship as" % kb["name"])
            lines.append("  review requests per its `.agents/knowledge-base.yaml` contract")
            lines.append("  (the synthesis-kb-edit and synthesis-okf skills follow it automatically).")
        lines.append("")
    if welcome.get("try_asking"):
        lines.append("## Things you can ask your AI assistant here")
        lines.append("")
        for item in welcome["try_asking"]:
            lines.append("- %s" % item)
        lines.append("")
    if welcome.get("docs"):
        lines.append("## Guides")
        lines.append("")
        for doc in welcome["docs"]:
            lines.append("- %s" % doc)
        lines.append("")
    lines.append("## Keeping things current")
    lines.append("")
    lines.append("Re-run the installer anytime; it is safe, updates everything, and")
    lines.append("repairs half-finished installs. The knowledge base is not exhaustive —")
    lines.append("if an answer seems missing or stale, say so and propose an update.")
    return "\n".join(lines) + "\n"


def phase_workspace(report, manifest, receipts, dry_run):
    if manifest.get("workspace_instructions") is False:
        report.add("workspace", SKIP, "workspace instruction generation disabled by manifest")
        return
    workspace = manifest["org"]["workspace"]
    ws_dir = WORKSPACES_ROOT / workspace
    if not dry_run:
        ws_dir.mkdir(parents=True, exist_ok=True)
    ensure_file(report, receipts, ws_dir / "AGENTS.md", workspace_agents_md(manifest),
                "workspace", dry_run)
    ensure_file(report, receipts, ws_dir / "CLAUDE.md", "@AGENTS.md\n", "workspace", dry_run)


def phase_migrations(report, manifest, receipts, dry_run):
    migrations = (manifest.get("migrations") or {}).get("skills") or []
    if not migrations:
        return
    targets = [HOME / ".claude" / "skills", HOME / ".agents" / "skills"]
    for mig in migrations:
        old = mig["from"]
        for target in targets:
            old_dir = target / old
            if not old_dir.is_dir():
                continue
            label = "%s in %s" % (old, target)
            if dry_run:
                report.add("migrations", CHANGED, "would %s %s" % (mig["action"], label))
                continue
            backup = archive_path(old_dir)
            shutil.move(str(old_dir), str(backup))
            if mig["action"] == "rename":
                new_dir = target / mig["to"]
                if new_dir.exists():
                    report.add("migrations", OK, "%s superseded by existing %s (old copy archived)" % (old, mig["to"]))
                else:
                    shutil.copytree(backup, new_dir)
                    report.add("migrations", CHANGED, "renamed %s -> %s" % (old, mig["to"]))
            else:
                note = mig.get("note")
                report.add("migrations", CHANGED, "retired %s (archived first)%s" % (label, " — " + note if note else ""))


def phase_welcome(report, manifest):
    welcome = (manifest or {}).get("welcome") or {}
    title = welcome.get("title") or "Welcome to the synthesis ecosystem."
    print("")
    print("=" * 66)
    print(title)
    if welcome.get("try_asking"):
        print("")
        print("Try asking your AI assistant:")
        for item in welcome["try_asking"]:
            print("  - %s" % item)
    if welcome.get("docs"):
        print("")
        print("Guides:")
        for doc in welcome["docs"]:
            print("  - %s" % doc)
    print("")
    print("Re-running this installer is always safe: it updates, repairs, and")
    print("never overwrites files you have edited yourself.")
    print("=" * 66)


# ---------------------------------------------------------------------------
# init-workspace scaffolder
# ---------------------------------------------------------------------------

def kb_agents_md(workspace):
    return """# ai-knowledge-%s

Personal knowledge base for the `%s` workspace, managed with synthesis
project management (three-tier context: CONTEXT.md / REFERENCE.md /
sessions/ per project).

## Structure

```
ai-knowledge-%s/
|-- projects/            # one folder per project + index.yaml registry
|   `-- index.yaml
`-- lessons/             # cross-project lessons, YYYY-MM-DD-slug.md
```

## Rules

- Active projects are listed in `projects/index.yaml` (status lives there,
  not in folder names).
- Each project keeps working memory in `CONTEXT.md` (<=150 lines), stable
  facts in `REFERENCE.md`, history in `sessions/YYYY-MM.md`.
- Lessons are date-prefixed files in `lessons/` — grep them at session start.
""" % (workspace, workspace, workspace)


INDEX_YAML_SEED = """# Projects Index
# Status values: active | paused | ongoing | completed | archived

projects: []
"""

LESSONS_README = """# Lessons

Cross-project lessons and patterns. One file per lesson, named
`YYYY-MM-DD-topic-slug.md`. No index file — `ls -t` shows recent.
"""


def init_workspace(report, receipts, workspace, dry_run, remote=None):
    ws_dir = WORKSPACES_ROOT / workspace
    repo = ws_dir / ("ai-knowledge-%s" % workspace)
    if dry_run:
        report.add("init-workspace", CHANGED, "would scaffold %s" % repo)
        return
    repo.mkdir(parents=True, exist_ok=True)
    ensure_file(report, receipts, repo / "AGENTS.md", kb_agents_md(workspace), "init-workspace", dry_run)
    ensure_file(report, receipts, repo / "CLAUDE.md", "@AGENTS.md\n", "init-workspace", dry_run)
    ensure_file(report, receipts, repo / "README.md",
                "# ai-knowledge-%s\n\nKnowledge base for the %s workspace. See AGENTS.md.\n" % (workspace, workspace),
                "init-workspace", dry_run)
    ensure_file(report, receipts, repo / ".gitignore", ".DS_Store\n", "init-workspace", dry_run)
    projects = repo / "projects"
    projects.mkdir(exist_ok=True)
    ensure_file(report, receipts, projects / "index.yaml", INDEX_YAML_SEED, "init-workspace", dry_run)
    lessons = repo / "lessons"
    lessons.mkdir(exist_ok=True)
    ensure_file(report, receipts, lessons / "README.md", LESSONS_README, "init-workspace", dry_run)
    if not (repo / ".git").exists():
        rc, _, err = git(["init", "-b", "main"], cwd=repo)
        if rc != 0:
            report.add("init-workspace", ERROR, "git init failed", hint=err.strip())
            return
        git(["add", "-A"], cwd=repo)
        rc, _, err = git(["commit", "-m", "Initialize ai-knowledge workspace"], cwd=repo)
        if rc == 0:
            report.add("init-workspace", CHANGED, "initialized git repository with first commit")
        else:
            report.add("init-workspace", ACTION, "repository created; first commit needs your git identity",
                       hint='Run:\n  git config --global user.name "Your Name"\n  git config --global user.email "you@example.com"\nthen commit inside %s' % repo)
    if remote:
        current = origin_url(repo)
        if current is None:
            git(["remote", "add", "origin", remote], cwd=repo)
            report.add("init-workspace", CHANGED, "added origin %s (push when ready)" % remote)
        elif current != remote:
            report.add("init-workspace", WARN, "origin already set to %s — left as-is" % current)


# ---------------------------------------------------------------------------
# doctor
# ---------------------------------------------------------------------------

def doctor(report, manifest, clients_wanted):
    rc, _, _ = run(["git", "--version"], timeout=15)
    if rc != 0:
        report.add("doctor", ERROR, "git unavailable — cannot verify anything else")
        return 2
    verified_any = False
    for name in clients_wanted:
        binary = resolve_client(name)
        if not binary:
            report.add("doctor", SKIP, "%s not installed" % name)
            continue
        state = plugin_enabled(name, binary)
        fallback = (HOME / (".claude" if name == "claude" else ".agents") / "skills")
        copies = sorted(p.name for p in fallback.glob("synthesis-*")) if fallback.exists() else []
        if state is True:
            verified_any = True
            report.add("doctor", OK, "%s: %s plugin enabled" % (name, PLUGIN_NAME))
        elif copies:
            verified_any = True
            report.add("doctor", OK, "%s: %d fallback skill copies present" % (name, len(copies)))
        elif state is None:
            report.add("doctor", WARN, "%s: plugin state could not be read" % name)
        else:
            report.add("doctor", ERROR, "%s: no plugin and no fallback copies" % name)
    if manifest:
        for repo in manifest.get("skills_repos") or []:
            cache = CACHE_DIR / repo["name"]
            if not (cache / ".git").exists():
                report.add("doctor", ERROR, "org skills source %s not present" % repo["name"])
                continue
            status_args = repo.get("status_args")
            if status_args and (cache / repo.get("installer", "install.sh")).exists():
                args = [str(HOME) if a == "$HOME" else a for a in status_args]
                env = {repo["source_env"]: str(cache)} if repo.get("source_env") else None
                rc, out, err = run(["sh", str(cache / repo.get("installer", "install.sh"))] + args, env=env, timeout=300)
                if rc == 0:
                    report.add("doctor", OK, "%s status clean" % repo["name"])
                else:
                    report.add("doctor", ERROR, "%s status reports problems" % repo["name"],
                               hint=(out + err).strip()[-300:])
            else:
                report.add("doctor", OK, "org skills source %s present" % repo["name"])
        receipts = Receipts()
        workspace = manifest["org"]["workspace"]
        for kb in manifest.get("knowledge_bases") or []:
            adopted = receipts.adopted(kb["name"])
            repo_path = Path(adopted) if adopted else WORKSPACES_ROOT / workspace / kb["name"]
            if not (repo_path / ".git").exists():
                report.add("doctor", ERROR, "knowledge base %s not installed" % kb["name"])
                continue
            if origin_url(repo_path) != kb["primary"]:
                report.add("doctor", WARN, "%s origin differs from manifest primary" % kb["name"])
            else:
                report.add("doctor", OK, "knowledge base %s present at %s" % (kb["name"], repo_path))
        ws_agents = WORKSPACES_ROOT / workspace / "AGENTS.md"
        if manifest.get("workspace_instructions") is False:
            pass
        elif ws_agents.exists():
            report.add("doctor", OK, "workspace instructions present")
        else:
            report.add("doctor", ERROR, "workspace AGENTS.md missing — run install")
    counts = report.counts()
    if counts.get(ERROR):
        return 1
    if not verified_any and not manifest:
        return 2
    return 0


# ---------------------------------------------------------------------------
# uninstall
# ---------------------------------------------------------------------------

def uninstall(report, dry_run):
    receipts = Receipts()
    files = list(receipts.data.get("generated_files", {}).items())
    if not files:
        report.add("uninstall", OK, "nothing recorded to remove")
    for path_str, meta in files:
        path = Path(path_str)
        if not path.exists():
            receipts.forget_file(path)
            continue
        current = path.read_text(encoding="utf-8")
        if sha256_text(current) != meta.get("sha256"):
            report.add("uninstall", WARN, "%s was edited after install — left in place" % path)
            continue
        if dry_run:
            report.add("uninstall", CHANGED, "would archive and remove %s" % path)
            continue
        shutil.copy2(path, archive_path(path))
        path.unlink()
        receipts.forget_file(path)
        report.add("uninstall", CHANGED, "archived and removed %s" % path)
    if not dry_run:
        receipts.save()
    report.add("uninstall", OK, "knowledge-base clones and plugins are never auto-removed",
               hint="Remove clones manually if desired; plugins via:  claude plugin uninstall %s@%s" % (PLUGIN_NAME, MARKETPLACE_NAME))


# ---------------------------------------------------------------------------
# main
# ---------------------------------------------------------------------------

def build_parser():
    parser = argparse.ArgumentParser(prog="onboard.py", description=__doc__,
                                     formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("command", nargs="?", default="install",
                        choices=["install", "update", "doctor", "init-workspace", "uninstall"])
    parser.add_argument("--manifest", type=Path, help="org onboarding manifest (.agents/onboarding.yaml)")
    parser.add_argument("--clients", default="claude,codex",
                        help="comma-separated clients to target (default: claude,codex)")
    parser.add_argument("--workspace", help="workspace name for init-workspace")
    parser.add_argument("--remote", help="optional git remote URL for init-workspace")
    parser.add_argument("--with-personal-workspace", metavar="NAME",
                        help="also scaffold a personal ai-knowledge-<NAME> workspace during install")
    parser.add_argument("--dry-run", action="store_true", help="show what would change; touch nothing")
    parser.add_argument("--no-plugin-cli", action="store_true",
                        help="skip client plugin CLIs; use file-copy fallback")
    parser.add_argument("--json", action="store_true", help="machine-readable output")
    return parser


def main(argv=None):
    args = build_parser().parse_args(argv)
    report = Report(dry_run=args.dry_run, as_json=args.json)
    clients_wanted = [c.strip() for c in args.clients.split(",") if c.strip()]
    manifest = None
    if args.manifest:
        try:
            manifest = load_manifest(args.manifest)
        except (ValueError, OSError) as exc:
            report.add("manifest", ERROR, str(exc))
            return finish(report, args, 2)

    if args.command == "doctor":
        code = doctor(report, manifest, clients_wanted)
        return finish(report, args, code)

    if args.command == "init-workspace":
        if not args.workspace:
            report.add("init-workspace", ERROR, "--workspace NAME is required")
            return finish(report, args, 2)
        receipts = Receipts()
        init_workspace(report, receipts, args.workspace, args.dry_run, remote=args.remote)
        if not args.dry_run:
            receipts.save()
        return finish(report, args, report.exit_code())

    if args.command == "uninstall":
        uninstall(report, args.dry_run)
        return finish(report, args, report.exit_code())

    # install / update
    receipts = Receipts()
    no_plugin_cli = args.no_plugin_cli or os.environ.get("SYNTHESIS_ONBOARD_NO_PLUGIN_CLI") == "1"
    clients = phase_preflight(report, clients_wanted)
    if clients is None:
        return finish(report, args, 2)
    phase_ecosystem(report, clients, args.dry_run, no_plugin_cli)
    if manifest:
        phase_org_skills(report, manifest, args.dry_run)
        phase_kbs(report, manifest, receipts, args.dry_run)
        phase_workspace(report, manifest, receipts, args.dry_run)
        phase_migrations(report, manifest, receipts, args.dry_run)
    if args.with_personal_workspace:
        init_workspace(report, receipts, args.with_personal_workspace, args.dry_run)
    if not args.dry_run:
        receipts.data.setdefault("runs", []).append(
            {"at": utcnow(), "command": args.command, "manifest": str(args.manifest) if args.manifest else None,
             "engine": ENGINE_VERSION})
        receipts.data["runs"] = receipts.data["runs"][-20:]
        receipts.save()
    code = report.exit_code()
    if not args.json:
        counts = report.counts()
        actions = [s for s in report.steps if s["status"] == ACTION]
        print("")
        if actions:
            print("One or more steps need you, then re-run this installer:")
            for step in actions:
                print("  -> %s" % step["detail"])
        elif code == 0:
            phase_welcome(report, manifest)
        summary = ", ".join("%s %s" % (v, k) for k, v in sorted(counts.items()))
        print("Result: %s" % summary)
    return finish(report, args, code)


def finish(report, args, code):
    if args.json:
        print(json.dumps({"engine": ENGINE_VERSION, "dry_run": args.dry_run,
                          "steps": report.steps, "counts": report.counts(),
                          "exit": code}, indent=2))
    return code


if __name__ == "__main__":
    sys.exit(main())
