#!/usr/bin/env python3
"""Guide a read-only nobrainer-tech-flow setup preflight and install an approved subset."""

from __future__ import annotations

import argparse
import base64
import hashlib
import importlib.util
import json
import os
import re
import shutil
import subprocess
import stat
import sys
import tempfile
import unicodedata
from dataclasses import dataclass
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import quote, urljoin, urlparse
from urllib.request import HTTPRedirectHandler, Request, build_opener

try:
    import tomllib
except ModuleNotFoundError:  # Python 3.10 can still inspect JSON-based clients.
    tomllib = None  # type: ignore[assignment]


ROOT = Path(__file__).resolve().parents[1]
SKILLS_DIR = ROOT / "skills"
PERSONALIZATION = ROOT / "scripts" / "install_personalization.py"
INSTALL_SKILLS = ROOT / "scripts" / "install_skills.py"
STATE_NAME = ".nobrainer-flow-onboarding.json"
CLIENTS = ("codex", "claude", "opencode", "copilot")


class InputRequired(ValueError):
    """The guided flow needs answers that were not supplied and cannot be asked."""


_SIBLINGS: dict[Path, object] = {}


def _sibling(path: Path):
    """Load a sibling script as a module, so client path rules have one source."""

    if path not in _SIBLINGS:
        spec = importlib.util.spec_from_file_location(f"_flow_{path.stem}", path)
        if spec is None or spec.loader is None:
            raise ValueError(f"cannot load {path}")
        module = importlib.util.module_from_spec(spec)
        # dataclasses resolve string annotations through sys.modules.
        sys.modules[spec.name] = module
        try:
            spec.loader.exec_module(module)
        except BaseException:
            sys.modules.pop(spec.name, None)
            raise
        _SIBLINGS[path] = module
    return _SIBLINGS[path]


def skills_destination(client: str, home: Path, environ) -> Path:
    return _sibling(INSTALL_SKILLS).client_destinations(environ, home, client)[client]


def instruction_file(client: str, home: Path, environ) -> Path:
    return _sibling(PERSONALIZATION).known_client_path(client, home, environ).path


CLIENT_VARIABLES = {"claude": "CLAUDE_CONFIG_DIR", "codex": "CODEX_HOME", "opencode": "XDG_CONFIG_HOME"}


def recorded_setting(client: str, home: Path, destination: object, target: object) -> str | None:
    """The variable setting under which a setup lands where its record says it went,
    as "NAME=value" or "NAME unset", or None when no one setting explains the record.

    The variable, not the record, decides where an undo may write, so this is what a
    person must set to undo a setup made with another value.
    """

    name = CLIENT_VARIABLES.get(client)
    if name is None:
        return None
    if client == "codex":
        if not isinstance(target, str) or not target:
            return None
        base = Path(target).parent
    else:
        if not isinstance(destination, str) or not destination:
            return None
        base = Path(destination).parent if client == "claude" else Path(destination).parent.parent
    for environ, setting in (({}, f"{name} unset"), ({name: str(base)}, f"{name}={base}")):
        try:
            if client != "codex" and (
                skills_destination(client, home, environ).expanduser().resolve()
                != Path(str(destination)).expanduser().resolve()
            ):
                continue
            if isinstance(target, str) and target and instruction_file(client, home, environ) != Path(target):
                continue
        except (OSError, ValueError):
            continue
        return setting
    return None


def recorded_skill(name: object) -> bool:
    """A name a setup record may hold: a current module, or one a release retired."""

    return isinstance(name, str) and (name in BY_SKILL or name in _sibling(INSTALL_SKILLS).RETIRED_LEGACY)


def owns_link(target: Path, name: str) -> bool:
    """True when target is this checkout's link for name; a retired module's link dangles."""

    if name in BY_SKILL:
        return links_to(target, SKILLS_DIR / name)
    return _sibling(INSTALL_SKILLS).is_exact_legacy_link(target, name)


def recorded_retired_args(record: dict | None) -> list[str]:
    """install_skills.py flags for retired modules a setup record says this setup linked."""

    names = (record or {}).get("created_skills", [])
    return [arg for name in names if isinstance(name, str) and name not in BY_SKILL for arg in ("--recorded-retired", name)]


def claim_retired_links(destination: Path, record: dict | None) -> list[tuple[Path, Path, tuple]]:
    """Move the retired links a record lists aside, verifying each moved entry.

    The claim is install_skills.py's atomic claim-and-verify: an entry that changed after
    the check is put back untouched. The claims are deleted only once the whole setup run
    has succeeded, and put back on any failure before that.
    """

    skills = _sibling(INSTALL_SKILLS)
    claims = []
    try:
        for name in (record or {}).get("created_skills", []):
            if not isinstance(name, str) or name in BY_SKILL:
                continue
            target = destination / name
            if not skills.is_exact_legacy_link(target, name):
                continue
            try:
                claim, expected = skills.claim_legacy_link(target, name)
            except skills.ClaimRestoreError:
                raise
            except RuntimeError as exc:
                print(f"PRESERVED: {target} changed during the setup and is left as it is ({exc})")
                continue
            claims.append((target, claim, expected))
    except (OSError, skills.ClaimRestoreError) as exc:
        errors = restore_retired_links(claims)
        if errors:
            raise skills.ClaimRestoreError(f"{exc}; {'; '.join(errors)}") from exc
        raise
    return claims


def restore_retired_links(claims: list[tuple[Path, Path, tuple]]) -> list[str]:
    """Put claimed retired links back; return what could not be restored."""

    errors = []
    for target, claim, expected in reversed(claims):
        try:
            # Moves the entry back without replacing a new one, and removes the private folder.
            _sibling(INSTALL_SKILLS).restore_claim(target, claim, expected)
        except (OSError, RuntimeError) as exc:
            errors.append(f"retired link restore failed: {exc}")
    return errors


def commit_retired_links(claims: list[tuple[Path, Path, tuple]]) -> None:
    """Delete the claimed retired links once the setup run has succeeded."""

    for target, claim, expected in claims:
        if _sibling(INSTALL_SKILLS).entry_fingerprint(claim) == expected:
            claim.unlink()
            claim.parent.rmdir()
            print(f"REMOVED_RETIRED: {target}")
        else:
            print(f"BACKUP_PRESERVED: the claimed retired link changed and is kept: {claim}")


def links_to(target: Path, source: Path) -> bool:
    """True when target is a symlink resolving to source; loops and dangling links are not."""

    try:
        return target.is_symlink() and target.resolve(strict=True) == source.resolve(strict=True)
    except (OSError, RuntimeError):
        return False


@dataclass(frozen=True)
class Item:
    id: str
    skill: str
    label: str
    profiles: tuple[str, ...]
    reason: str


# IDs are a user-facing selection contract. Keep them stable when descriptions
# or the repository's directory ordering changes; retired IDs must not be reused.
ITEMS = (
    Item("01", "nobrainer-tech-flow", "Task orchestration", ("all",), "one task entrypoint and completion checks"),
    Item("02", "nobrainer-auto-fine-tune", "Capability audit", ("all",), "read-only client/model/context/delegation audit; preserve MAIN"),
    Item("03", "nobrainer-build", "Implementation", ("software-development", "operations"), "bounded code and configuration changes with verification"),
    Item("04", "nobrainer-review", "Code review", ("software-development",), "independent checks for defects, security and maintainability"),
    Item("05", "nobrainer-security", "Security review", ("software-development", "operations"), "trust-boundary and security analysis"),
    Item("06", "nobrainer-research", "Research", ("research", "product"), "source-backed answers for uncertain or changing questions"),
    Item("07", "nobrainer-writing", "Writing", ("writing", "product", "research"), "clear, fact-preserving user-facing and technical prose"),
    Item("08", "nobrainer-browser", "Browser work", ("software-development", "operations"), "inspect and verify rendered web behavior"),
    Item("09", "nobrainer-decide", "Decision support", ("product", "research", "operations"), "compare real options against stated constraints"),
    Item("10", "nobrainer-dispatcher", "Work queue", ("software-development", "operations"), "order ready independent units and manage backpressure"),
    Item("11", "nobrainer-sessions", "Session continuity", ("software-development", "operations", "research"), "checkpoint, hand off and verify task continuity"),
    Item("12", "nobrainer-skill-doctor", "Skill quality", ("software-development",), "audit skill structure and practical behavior"),
    Item("13", "nobrainer-autoimprove", "Measured improvement", ("software-development", "product"), "run bounded baseline/candidate/holdout improvement work"),
    Item("14", "nobrainer-spec-driven-development", "Specifications", ("software-development", "product"), "define durable contracts for architectural changes"),
    Item("15", "nobrainer-rca", "Root-cause analysis", ("software-development", "operations"), "diagnose repeated or causally unclear failures"),
    Item("16", "nobrainer-team", "Capability-to-role design", ("software-development", "operations"), "assign available capabilities to bounded work"),
    Item("17", "nobrainer-context", "Project instructions", ("software-development", "operations"), "one tidy set of project instructions for every coding agent"),
    Item("18", "nobrainer-wiki", "Durable knowledge", ("research", "product", "operations"), "query and maintain a sourced project knowledge base"),
)
BY_ID = {item.id: item for item in ITEMS}
BY_SKILL = {item.skill: item for item in ITEMS}
PROFILE_CHOICES = ("software-development", "research", "writing", "operations", "product")
MAX_LOCAL_FILE_BYTES = 64 * 1024
MAX_API_RESPONSE_BYTES = 1_200_000


def safe_repo_url(value: str) -> str:
    parsed = urlparse(value.strip())
    if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password:
        raise ValueError("repo URL must be an HTTPS URL without embedded credentials")
    if not re.fullmatch(r"[A-Za-z0-9.-]+", parsed.hostname or ""):
        raise ValueError("repo URL host is invalid")
    return value.strip().rstrip("/")


def display_repo_url(value: str) -> str:
    parsed = urlparse(value)
    return f"{parsed.scheme}://{parsed.hostname}{parsed.path.rstrip('/')}"


@dataclass(frozen=True)
class RepositoryContext:
    status: str
    signals: str = ""
    files: tuple[str, ...] = ()
    summary: str = ""
    existing_skills: tuple[str, ...] = ()


def _read_local_text(path: Path) -> str | None:
    """Read a small regular source file without following repo-owned symlinks."""

    for parent in reversed(path.parents[:-1]):
        try:
            if stat.S_ISLNK(parent.lstat().st_mode):
                return None
        except OSError:
            return None
    descriptor = -1
    try:
        descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
        metadata = os.fstat(descriptor)
        if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > MAX_LOCAL_FILE_BYTES:
            return None
        with os.fdopen(descriptor, "rb") as handle:
            descriptor = -1
            return handle.read(MAX_LOCAL_FILE_BYTES + 1).decode("utf-8", errors="replace")
    except OSError:
        return None
    finally:
        if descriptor >= 0:
            os.close(descriptor)


REPO_TEXT_FILES = (
    "README.md",
    "AGENTS.md",
    "CLAUDE.md",
    "GEMINI.md",
    ".github/copilot-instructions.md",
    "pyproject.toml",
    "package.json",
    "Cargo.toml",
    "go.mod",
    "requirements.txt",
)
SKILL_DIRS = (".agents/skills", ".claude/skills", ".codex/skills", ".opencode/skills")


def inspect_local_repository(root: Path) -> RepositoryContext:
    root = root.expanduser().resolve(strict=True)
    if not root.is_dir():
        raise ValueError(f"local repository path is not a directory: {root}")
    try:
        git_marker = (root / ".git").lstat()
    except OSError as exc:
        raise ValueError(f"local path is not a Git checkout: {root}") from exc
    if stat.S_ISLNK(git_marker.st_mode) or not (
        stat.S_ISDIR(git_marker.st_mode) or stat.S_ISREG(git_marker.st_mode)
    ):
        raise ValueError(f"local Git metadata path is not a regular checkout marker: {root}")
    documents: dict[str, str] = {}
    for relative in REPO_TEXT_FILES:
        value = _read_local_text(root / relative)
        if value is not None:
            documents[relative] = value
    skill_names: set[str] = set()
    for relative in SKILL_DIRS:
        folder = root / relative
        try:
            metadata = folder.lstat()
        except OSError:
            continue
        if not folder.is_dir() or folder.is_symlink():
            continue
        for child in folder.iterdir():
            if child.name in BY_SKILL and child.is_dir():
                skill_names.add(child.name)
    texts = "\n".join(documents.values())
    mention = {item.skill for item in ITEMS if re.search(rf"(?<![A-Za-z0-9_-]){re.escape(item.skill)}(?![A-Za-z0-9_-])", texts)}
    file_list = tuple(documents)
    summary = f"local files read={','.join(file_list) or 'none'}; project skill directories={len(skill_names)}"
    return RepositoryContext(
        "LOCAL_READ_ONLY",
        signals=texts,
        files=file_list,
        summary=summary,
        existing_skills=tuple(sorted(skill_names | mention)),
    )


def _safe_label(value: str, limit: int = 100) -> str:
    cleaned = "".join(char if char.isprintable() and char not in "`<>" else " " for char in value)
    return " ".join(cleaned.split())[:limit]


def github_repo_parts(repo_url: str) -> tuple[str, str] | None:
    parsed = urlparse(repo_url)
    if (parsed.hostname or "").lower() not in {"github.com", "www.github.com"}:
        return None
    parts = [part for part in parsed.path.split("/") if part]
    if len(parts) < 2:
        raise ValueError("GitHub repository link must include owner and repository")
    owner, repository = parts[:2]
    repository = repository.removesuffix(".git")
    valid = re.compile(r"^[A-Za-z0-9_.-]{1,100}$")
    if not valid.fullmatch(owner) or not valid.fullmatch(repository) or owner in {".", ".."} or repository in {".", ".."}:
        raise ValueError("GitHub owner or repository name is invalid")
    return owner, repository


class _GitHubRedirectHandler(HTTPRedirectHandler):
    def redirect_request(self, request, file_pointer, code, message, headers, new_url):
        target = urlparse(urljoin(request.full_url, new_url))
        if target.scheme != "https" or target.hostname != "api.github.com":
            return None
        return super().redirect_request(request, file_pointer, code, message, headers, new_url)


def _open_github_api(request: Request, timeout: int):
    return build_opener(_GitHubRedirectHandler()).open(request, timeout=timeout)


def _github_json(endpoint: str, timeout: int = 8) -> tuple[dict[str, object] | None, str]:
    """Read a bounded GitHub REST response, preferring an existing gh login."""

    timeout = min(timeout, 4)
    gh = shutil.which("gh")
    if gh:
        try:
            result = subprocess.run(
                [gh, "api", "--hostname", "github.com", endpoint],
                cwd=ROOT,
                # GitHub JSON is UTF-8; the Windows code page cannot decode all of it.
                encoding="utf-8",
                errors="replace",
                capture_output=True,
                timeout=timeout,
                check=False,
            )
            if result.returncode == 0 and len(result.stdout.encode("utf-8")) <= MAX_API_RESPONSE_BYTES:
                value = json.loads(result.stdout)
                if isinstance(value, dict):
                    return value, "GITHUB_READ_ONLY_GH"
            if result.returncode == 0:
                return None, "RESPONSE_TOO_LARGE"
        except (OSError, subprocess.TimeoutExpired, json.JSONDecodeError):
            pass

    request = Request(
        f"https://api.github.com/{endpoint}",
        headers={
            "Accept": "application/vnd.github+json",
            "User-Agent": "nobrainer-tech-flow-setup-preflight",
            "X-GitHub-Api-Version": "2022-11-28",
        },
        method="GET",
    )
    try:
        with _open_github_api(request, timeout=timeout) as response:
            raw = response.read(MAX_API_RESPONSE_BYTES + 1)
        if len(raw) > MAX_API_RESPONSE_BYTES:
            return None, "RESPONSE_TOO_LARGE"
        value = json.loads(raw)
        if isinstance(value, dict):
            return value, "GITHUB_READ_ONLY_API"
        return None, "INVALID_RESPONSE"
    except HTTPError as exc:
        return None, f"HTTP_{exc.code}"
    except (OSError, URLError, TimeoutError, json.JSONDecodeError):
        return None, "NETWORK_UNAVAILABLE"


def inspect_github_repository(repo_url: str) -> RepositoryContext:
    parts = github_repo_parts(repo_url)
    if parts is None:
        return RepositoryContext("UNSUPPORTED_HOST")
    owner, repository = parts
    owner_path, repo_path = quote(owner, safe=""), quote(repository, safe="")
    metadata, source = _github_json(f"repos/{owner_path}/{repo_path}")
    if metadata is None:
        return RepositoryContext(source)
    readme, readme_source = _github_json(f"repos/{owner_path}/{repo_path}/readme")
    text = ""
    if readme is not None and readme.get("encoding") == "base64" and isinstance(readme.get("content"), str):
        try:
            content = base64.b64decode(readme["content"], validate=True)
            if len(content) <= MAX_LOCAL_FILE_BYTES:
                text = content.decode("utf-8", errors="replace")
        except (ValueError, TypeError):
            text = ""
    language = metadata.get("language") if isinstance(metadata.get("language"), str) else "UNKNOWN"
    description = metadata.get("description") if isinstance(metadata.get("description"), str) else ""
    topics = metadata.get("topics") if isinstance(metadata.get("topics"), list) else []
    safe_topics = [
        _safe_label(topic, 40)
        for topic in topics
        if isinstance(topic, str) and re.fullmatch(r"[A-Za-z0-9_.-]{1,40}", topic)
    ][:12]
    signals = " ".join((str(language), description[:1000], " ".join(safe_topics), text))
    summary = f"GitHub metadata read; language={_safe_label(str(language), 40)}; README={'read' if text else 'unavailable'}"
    # Never print README text, repo instructions or descriptions: all are
    # untrusted data used only as bounded keyword context for recommendations.
    return RepositoryContext(source + (f"+README_{readme_source}" if readme_source else ""), signals=signals, summary=summary)


# Each client keeps its settings file next to its global instruction file.
CLIENT_CONFIG_FILES = {
    "codex": "config.toml",
    "claude": "settings.json",
    "opencode": "opencode.json",
    "copilot": "config.json",
}


def inspect_client_config(client: str, home: Path, environ=None) -> dict[str, str]:
    environ = {} if environ is None else environ
    path = instruction_file(client, home.expanduser(), environ).parent / CLIENT_CONFIG_FILES[client]
    report = {
        "config_path": str(path),
        "config_status": "MISSING",
        "configured_main_model": "UNKNOWN",
        "configured_effort": "UNKNOWN",
        "active_profile": "UNKNOWN",
        "advertised_models": "UNKNOWN",
        "callable_workers": "UNKNOWN",
        "runtime_main_and_effort": "UNKNOWN",
        "runtime_context": "UNKNOWN",
    }
    try:
        metadata = path.lstat()
    except OSError:
        return report
    if path.is_symlink() or not path.is_file() or metadata.st_size > MAX_LOCAL_FILE_BYTES:
        report["config_status"] = "PRESENT_UNREADABLE_OR_UNSUPPORTED"
        return report
    try:
        text = path.read_text(encoding="utf-8")
        if client == "codex":
            config = tomllib.loads(text)
        else:
            config = json.loads(text)
        if not isinstance(config, dict):
            report["config_status"] = "INVALID_FORMAT"
            return report
        model = config.get("model")
        effort = config.get("model_reasoning_effort") or config.get("reasoningEffort")
        if isinstance(model, str) and len(model) <= 128:
            report["configured_main_model"] = _safe_label(model, 128)
        if isinstance(effort, str) and len(effort) <= 64:
            report["configured_effort"] = _safe_label(effort, 64)
        report["config_status"] = "READ_OK_CONFIGURED_ONLY"
    except (OSError, UnicodeDecodeError, json.JSONDecodeError, tomllib.TOMLDecodeError):
        report["config_status"] = "PARSE_ERROR"
    return report


def ask_if_missing(args: argparse.Namespace) -> None:
    print("nobrainer-tech-flow provides one task entrypoint, a required read-only capability audit, and optional specialists for implementation, research, writing, operations and product work.")
    questions = (
        ("work_profile", "Work type (software-development/research/writing/operations/product)", PROFILE_CHOICES),
        ("goal", "What outcome do you want from this setup?", None),
        ("tools", "Which client and tools do you already use?", None),
        ("existing_setup", "What relevant skills, instructions or workflows are already installed?", None),
    )
    missing = [attribute for attribute, _, _ in questions if not getattr(args, attribute)]
    if missing and not stdin_is_terminal():
        flags = ", ".join("--" + attribute.replace("_", "-") for attribute in missing)
        raise InputRequired(f"answers are missing and no terminal is attached; pass {flags}")
    for attribute, prompt, choices in questions:
        value = getattr(args, attribute)
        if value:
            continue
        while True:
            try:
                entered = input(f"{prompt}: ").strip()
            except EOFError:
                raise InputRequired(f"input ended before {attribute.replace('_', '-')} was answered") from None
            if entered and (choices is None or entered in choices):
                setattr(args, attribute, entered)
                break
            suffix = f" Choose one of: {', '.join(choices)}." if choices else " Enter a short answer."
            print("Please answer the question." + suffix, file=sys.stderr)


MATCH_TERMS = {
    "01": ("task", "complete", "orchestrat"),
    "02": ("model", "context", "agent", "capability"),
    "03": ("code", "build", "app", "software", "ship", "python"),
    "04": ("review", "defect", "quality", "code"),
    "05": ("security", "auth", "secret", "threat"),
    "06": ("research", "compare", "evidence", "source"),
    "07": ("write", "writing", "copy", "document", "content"),
    "08": ("browser", "web", "ui", "render"),
    "09": ("decide", "decision", "choice", "compare"),
    "10": ("queue", "parallel", "batch", "dispatch"),
    "11": ("session", "context", "handoff", "continuity"),
    "12": ("skill", "audit", "catalog", "quality"),
    "13": ("improve", "experiment", "baseline", "measure"),
    "14": ("spec", "architecture", "contract", "migration"),
    "15": ("root cause", "failure", "incident", "debug"),
    "16": ("team", "role", "delegate", "capability"),
    "17": ("project", "context", "instructions", "agents.md"),
    "18": ("wiki", "knowledge", "notes", "sources"),
}


def recommendations(
    profile: str,
    goal: str,
    tools: str,
    existing: str,
    present: set[str],
    repository: RepositoryContext | None = None,
) -> list[tuple[Item, int, str]]:
    context = f"{goal} {tools}".casefold()
    already = existing.casefold()
    repo_text = repository.signals.casefold() if repository else ""
    repo_existing = set(repository.existing_skills) if repository else set()
    ranked: list[tuple[Item, int]] = []
    for item in ITEMS:
        if item.id in {"01", "02"}:
            continue
        profile_match = profile in item.profiles
        goal_hits = [term for term in MATCH_TERMS[item.id] if term in context]
        repo_hits = [term for term in MATCH_TERMS[item.id] if term in repo_text]
        existing_hit = (
            item.skill.casefold() in already
            or item.label.casefold() in already
            or item.skill in repo_existing
        )
        actual_present = item.id in present
        score = 4 + (3 if profile_match else 0) + (2 if goal_hits else 0)
        if any(term in tools.casefold() for term in MATCH_TERMS[item.id]):
            score += 1
        if repo_hits:
            score += 1
        if existing_hit or actual_present:
            score -= 3
        score = max(1, min(10, score))
        if profile_match or goal_hits:
            reasons = []
            if profile_match:
                reasons.append(f"fits {profile}")
            if goal_hits:
                reasons.append("task/tool match: " + ", ".join(goal_hits[:3]))
            if repo_hits:
                reasons.append("repository context: " + ", ".join(repo_hits[:3]))
            if existing_hit or actual_present:
                reasons.append("possible overlap; verify current setup before adding")
            ranked.append((item, score, "; ".join(reasons)))
    # The core skill and standard first-setup audit are required and shown
    # separately; keep the optional recommendation list useful and short.
    return sorted(ranked, key=lambda row: (-row[1], row[0].id))[:6]


def installed_ids(destination: Path) -> set[str]:
    found: set[str] = set()
    for item in ITEMS:
        if links_to(destination / item.skill, SKILLS_DIR / item.skill):
            found.add(item.id)
    return found


def target_state(destination: Path, item: Item) -> str:
    target = destination / item.skill
    if not target.exists() and not target.is_symlink():
        return "MISSING"
    if links_to(target, SKILLS_DIR / item.skill):
        return "CURRENT"
    return "CONFLICT"


def selected_ids(raw: str | None) -> list[str]:
    if not raw:
        return []
    chosen = [part.strip().zfill(2) for part in raw.split(",") if part.strip()]
    if not chosen or len(chosen) != len(set(chosen)):
        raise ValueError("selection must be a comma-separated list of unique IDs")
    unknown = sorted(set(chosen) - set(BY_ID))
    if unknown:
        raise ValueError(f"unknown selection ID(s): {', '.join(unknown)}")
    return sorted(chosen)


def require_utf8(*paths: Path) -> None:
    """Refuse a path the helpers cannot print back exactly: the rollback state is read from
    their output, and a mangled path would make a finished setup impossible to undo."""

    for path in paths:
        try:
            str(path).encode("utf-8")
        except UnicodeEncodeError:
            raise ValueError(f"path is not valid UTF-8, so the setup cannot record it: {path!r}") from None
        if any(unicodedata.category(char) in ("Cc", "Zl", "Zp") for char in str(path)):
            raise ValueError(
                f"path contains a line break or control character, so the setup cannot record it: {path!r}"
            )


def stdin_is_terminal() -> bool:
    """False when there is nobody to ask: no stdin at all, a closed one, or a pipe."""

    try:
        return sys.stdin is not None and sys.stdin.isatty()
    except ValueError:
        return False


def profile_failure_hint(output: str) -> None:
    if "takes precedence over" in output:
        print(
            "HINT: the guided setup cannot write to AGENTS.override.md; remove or empty "
            "that file, or run scripts/install_personalization.py --client codex "
            "--path <file> yourself."
        )


def run(command: list[str], hide: tuple[str, ...] = ()) -> tuple[int, str]:
    """Run a helper and echo its output, minus lines that start with a hidden prefix."""

    # The helpers write UTF-8 whatever the console code page is.
    result = subprocess.run(
        command, cwd=ROOT, encoding="utf-8", errors="replace", capture_output=True, check=False
    )
    output = result.stdout + result.stderr
    shown = "".join(
        line for line in output.splitlines(keepends=True) if not line.startswith(hide)
    )
    if shown:
        print(shown, end="" if shown.endswith("\n") else "\n")
    return result.returncode, output


def remove_exact_created_links(destination: Path, names: list[str]) -> None:
    for name in names:
        if not recorded_skill(name):
            continue
        target = destination / name
        if owns_link(target, name):
            target.unlink()


def physical_path(path: Path) -> Path:
    """PATH with the links on the way resolved, whether or not all of it exists yet."""

    try:
        return path.resolve()
    except (OSError, RuntimeError):
        return path


def missing_directories(*paths: Path) -> list[Path]:
    """The folders on the way to PATHS that do not exist yet, outermost first.

    A link counts as existing: it is somebody's link, never a folder this setup made. So
    does a spelling with ``..`` that resolves to an existing folder: ``profile/missing/..``
    does not exist before ``missing`` is made, but it is ``profile``, which was there
    already. The folders above such a spelling are still checked, as ``missing`` is.
    """

    missing: list[Path] = []
    for path in paths:
        chain: list[Path] = []
        for candidate in (path, *path.parents):
            if candidate.exists() or candidate.is_symlink() or physical_path(candidate).exists():
                continue
            chain.append(candidate)
        for candidate in reversed(chain):
            if candidate not in missing:
                missing.append(candidate)
    return missing


def recorded_directories(state: dict[str, object], record: Path) -> list[Path]:
    """The folders a setup record says the setup created, or ValueError when it cannot be true.

    A folder the setup made lies on the way to its skills folder, to the folder of its
    instruction file or to the folder of the RECORD itself, so any other entry (a relative path, a root, a path with
    ``..``, a folder elsewhere) means a damaged record, and an undo must not act on it.
    A path may be spelled with or without the links on the way, so both spellings count. A
    folder the setup made is listed as it really is, and a base spelled with ``..`` reaches
    it on the way without lying below it, so the real form of each folder on that way counts.
    An entry may be listed once. Two entries may still lead to one folder: a link put in place
    of one of them since the setup does that. The undo removes no folder of such a group.
    """

    raw = state.get("created_dirs", [])
    if not isinstance(raw, list) or any(not isinstance(item, str) for item in raw):
        raise ValueError("created_dirs must list each folder the setup created once")
    bases = [Path(str(state["destination"]))]
    profile = state.get("profile")
    target = profile.get("target") if isinstance(profile, dict) else None
    if isinstance(target, str) and target:
        bases.append(Path(target).parent)
    bases.append(record.parent)
    places = [form for base in bases for step in (base, *base.parents) for form in (step, physical_path(step))]
    directories: list[Path] = []
    seen: set[Path] = set()
    for item in raw:
        path = Path(item)
        forms = (path, physical_path(path))
        if (
            not path.is_absolute()
            or ".." in path.parts
            or path == Path(path.anchor)
            or not any(form == place or form in place.parents for form in forms for place in places)
        ):
            raise ValueError(f"created_dirs names a folder this setup cannot have created: {item}")
        if path in seen:
            raise ValueError("created_dirs must list each folder the setup created once")
        seen.add(path)
        directories.append(path)
    return directories


def is_link(path: Path) -> bool:
    """A symbolic link, or on Windows any reparse point such as a directory junction.

    ``is_symlink`` misses a junction before Python 3.12, and removing one with ``rmdir``
    disconnects whatever uses the folder it points to, even when that folder is not empty.
    """

    if path.is_symlink():
        return True
    try:
        attributes = getattr(os.lstat(path), "st_file_attributes", 0)
    except OSError:
        return False
    return bool(attributes & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0))


def remove_empty_directories(directories: list[Path]) -> tuple[list[Path], list[Path], list[Path]]:
    """Remove each of these folders that is still empty, innermost first.

    Returns the folders removed, the folders kept because a folder above them is a link
    now, and the folders kept because another entry leads to the same folder. The record
    lists each folder with the links on the way resolved, so such a link means the profile
    was rearranged since the setup, and removing the folder would act on another tree. Two
    entries that lead to one folder cannot both be what the setup made, and the record
    cannot say which one is, so neither is removed.
    """

    real = [physical_path(path) for path in directories]
    shared = {path for path, form in zip(directories, real) if real.count(form) > 1}
    removed: list[Path] = []
    kept: list[Path] = []
    kept_shared: list[Path] = []
    for path in sorted(directories, key=lambda item: len(item.parts), reverse=True):
        if path in shared:
            kept_shared.append(path)
            continue
        try:
            if is_link(path) or not path.is_dir():
                continue
            if any(is_link(parent) for parent in path.parents):
                kept.append(path)
                continue
            path.rmdir()
        except OSError:
            continue
        removed.append(path)
    return removed, kept, kept_shared


def state_path(home: Path, override: Path | None, client: str) -> Path:
    if override is not None:
        return override.expanduser()
    suffix = STATE_NAME.removesuffix(".json")
    return (home / f"{suffix}-{client}.json").expanduser()


def legacy_state_path(home: Path, override: Path | None) -> Path | None:
    return None if override is not None else home / STATE_NAME


def legacy_state_client(state: dict[str, object], home: Path) -> str | None:
    """Infer the owner of a v1 single-client state without mutating it."""

    destinations = {
        "codex": home / ".agents" / "skills",
        "claude": home / ".claude" / "skills",
        "opencode": home / ".config" / "opencode" / "skills",
        "copilot": home / ".copilot" / "skills",
    }
    destination = state.get("destination")
    if isinstance(destination, str):
        resolved = Path(destination).expanduser().resolve()
        for client, path in destinations.items():
            if resolved == path.resolve():
                return client
    profile = state.get("profile")
    target = profile.get("target") if isinstance(profile, dict) else None
    if isinstance(target, str):
        targets = {
            "codex": home / ".codex" / "AGENTS.md",
            "claude": home / ".claude" / "CLAUDE.md",
            "opencode": home / ".config" / "opencode" / "AGENTS.md",
            "copilot": home / ".copilot" / "copilot-instructions.md",
        }
        for client, path in targets.items():
            if Path(target).expanduser() == path:
                return client
    return None


def read_state_file(path: Path) -> dict[str, object]:
    if path.is_symlink() or not path.is_file():
        raise ValueError(f"setup state path is not a regular file: {path}")
    loaded = json.loads(path.read_text(encoding="utf-8"))
    if (
        not isinstance(loaded, dict)
        or not isinstance(loaded.get("destination"), str)
        or not isinstance(loaded.get("created_skills"), list)
        or not isinstance(loaded.get("profile"), dict)
        or not all(recorded_skill(name) for name in loaded.get("created_skills", []))
    ):
        raise ValueError(f"refusing to overwrite unrecognized setup state: {path}")
    try:
        recorded_directories(loaded, path)
    except ValueError as exc:
        raise ValueError(f"refusing to overwrite unrecognized setup state: {path} ({exc})") from None
    return loaded


def save_state(path: Path, state: dict[str, object]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
    temporary = Path(temporary_name)
    try:
        with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
            handle.write(json.dumps(state, indent=2) + "\n")
            handle.flush()
            os.fsync(handle.fileno())
        temporary.chmod(0o600)
        os.replace(temporary, path)
    except Exception:
        temporary.unlink(missing_ok=True)
        raise


BLOCK_START = b"<!-- NOBRAINER-TECH-FLOW:START -->"
BLOCK_END = b"<!-- NOBRAINER-TECH-FLOW:END -->"


def block_span(data: bytes) -> tuple[int, int] | None:
    start = data.find(BLOCK_START)
    end = data.find(BLOCK_END)
    if start < 0 or end < start:
        return None
    return start, end + len(BLOCK_END)


def _fills_line(data: bytes, start: int, end: int) -> bool:
    """Whether data[start:end] is a whole line, a CR before its line break allowed."""

    if start and data[start - 1 : start] != b"\n":
        return False
    after = data[end : end + 2]
    return after in (b"", b"\r", b"\r\n") or after.startswith(b"\n")


def block_lines(data: bytes) -> tuple[int, int] | None:
    """The managed block as whole lines, the END line's line break included, when
    each marker stands on a line of its own, as scripts/install.sh requires."""

    span = block_span(data)
    if span is None:
        return None
    start, end = span
    if not (
        _fills_line(data, start, start + len(BLOCK_START))
        and _fills_line(data, end - len(BLOCK_END), end)
    ):
        return None
    line_break = data.find(b"\n", end)
    return start, len(data) if line_break < 0 else line_break + 1


def restored_content(current: bytes, original: bytes | None) -> bytes:
    """The instruction file with its managed block put back as it was before the setup.

    Everything outside the block stays as it is now, so edits made since the setup
    survive. When nothing outside the block changed, the result is the original file
    byte for byte: the setup added one line break before the block when the file did
    not end with one, and that goes too. A block on lines of its own is replaced as
    whole lines, as scripts/install.sh does, so both undo to the same bytes.
    """

    whole = block_lines(current)
    span = whole or block_span(current)
    if span is None:
        raise ValueError("managed block not found")
    before = b""
    if original is not None:
        original_span = (block_lines(original) if whole else None) or block_span(original)
        if original_span is not None:
            before = original[original_span[0] : original_span[1]]
    after = current[span[1] :]
    if whole and before and not before.endswith(b"\n") and after:
        # The block ended the original file; the text now after it keeps its own line.
        before += b"\n"
    restored = current[: span[0]] + before + after
    if original is not None and restored == original + b"\n":
        return original
    return restored


def save_preimage(path: Path, preimage: tuple[bytes, int]) -> Path:
    """Keep the file as it was before the setup next to it, as the helper's backups are."""

    backup = _sibling(PERSONALIZATION).backup_path(path)
    data, mode = preimage
    with backup.open("xb") as handle:
        handle.write(data)
    os.chmod(backup, mode)
    return backup


def read_file_preimage(path: Path) -> tuple[bytes, int] | None:
    """Capture an exact regular-file preimage for a bounded rollback."""
    try:
        metadata = path.lstat()
    except FileNotFoundError:
        return None
    if not stat.S_ISREG(metadata.st_mode):
        raise ValueError(f"rollback target is not a regular file: {path}")
    return path.read_bytes(), stat.S_IMODE(metadata.st_mode)


def restore_file_preimage(
    path: Path,
    expected_sha256: str,
    preimage: tuple[bytes, int] | None,
) -> bool:
    """Restore only a file still matching this invocation's write."""
    try:
        metadata = path.lstat()
    except FileNotFoundError:
        return preimage is None
    if not stat.S_ISREG(metadata.st_mode):
        return False
    current = path.read_bytes()
    if preimage is not None and current == preimage[0] and stat.S_IMODE(metadata.st_mode) == preimage[1]:
        return True
    if hashlib.sha256(current).hexdigest() != expected_sha256:
        return False
    if preimage is None:
        latest = path.lstat()
        if (latest.st_dev, latest.st_ino) != (metadata.st_dev, metadata.st_ino) or path.read_bytes() != current:
            return False
        path.unlink()
        return not path.exists() and not path.is_symlink()

    descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.rollback.", dir=path.parent)
    temporary = Path(temporary_name)
    try:
        with os.fdopen(descriptor, "wb") as handle:
            handle.write(preimage[0])
            handle.flush()
            os.fsync(handle.fileno())
        temporary.chmod(preimage[1])
        latest = path.lstat()
        if (latest.st_dev, latest.st_ino) != (metadata.st_dev, metadata.st_ino) or path.read_bytes() != current:
            return False
        os.replace(temporary, path)
    finally:
        temporary.unlink(missing_ok=True)
    restored = path.lstat()
    return (
        stat.S_ISREG(restored.st_mode)
        and path.read_bytes() == preimage[0]
        and stat.S_IMODE(restored.st_mode) == preimage[1]
    )


def print_changed_profile(target: Path, backup: object) -> None:
    print(f"PRESERVED: personalization changed since setup: {target}; it is left exactly as it is, managed block included")
    by_hand = f"delete the lines from {BLOCK_START.decode()} to {BLOCK_END.decode()} yourself and undo again"
    # Running the setup again keeps the backup the record names, and the undo after it puts
    # the block back from that backup. With the backup gone or unreadable that undo would stop,
    # so only the way by hand is offered.
    backup_file = Path(str(backup)) if backup else None
    if backup_file and (backup_file.is_symlink() or not backup_file.is_file() or not os.access(backup_file, os.R_OK)):
        print(
            f"HINT: to take the managed block out as well, {by_hand}; your edits outside the block stay. "
            f"Running the setup again would not help: the backup {backup_file} it keeps in the record is gone or unreadable."
        )
        return
    print(
        "HINT: to take the managed block out as well, run the setup again with --apply, which records the "
        f"file as it is now, then undo again; your edits outside the block stay. Or {by_hand}."
    )


def read_or_report(path: Path) -> bytes | None:
    """PATH's bytes, or None after saying why the undo stops before it changes anything.

    A file that cannot be read cannot be told apart from one without the managed block, and
    taking it for one would remove the links and the record while the block stays.
    """

    try:
        return path.read_bytes()
    except OSError:
        print(f"ERROR: cannot read {path}, so the undo cannot tell what it holds; nothing was changed", file=sys.stderr)
        print("HINT: make it readable for this account, then undo again", file=sys.stderr)
        return None


def rollback(args: argparse.Namespace, state_file: Path) -> int:
    try:
        metadata = state_file.lstat()
    except FileNotFoundError:
        print(f"ERROR: rollback state not found: {state_file}", file=sys.stderr)
        return 3
    except OSError as exc:
        print(f"ERROR: cannot check {state_file}: {exc.strerror}; nothing was changed", file=sys.stderr)
        print("HINT: give this account access to the folders on that path, then undo again", file=sys.stderr)
        return 3
    if state_file.is_symlink() or not state_file.is_file():
        print(f"ERROR: rollback state must be a regular file: {state_file}", file=sys.stderr)
        return 3
    state = json.loads(state_file.read_text(encoding="utf-8"))
    if not isinstance(state, dict) or not isinstance(state.get("destination"), str):
        print("ERROR: invalid rollback state", file=sys.stderr)
        return 3
    destination = Path(state["destination"])
    created = state.get("created_skills", [])
    if not isinstance(created, list) or len(created) != len(set(created)) or not all(recorded_skill(name) for name in created):
        print("ERROR: rollback state contains invalid skill ownership entries", file=sys.stderr)
        return 3
    try:
        directories = recorded_directories(state, state_file)
    except ValueError as exc:
        print(f"ERROR: rollback state is not credible: {exc}", file=sys.stderr)
        return 3
    profile = state.get("profile", {})
    target_path = Path(str(profile.get("target", ""))) if profile else None
    backup = profile.get("backup") if profile else None
    if target_path:
        allowed_paths = set()
        for client in CLIENTS:
            for environ in (args.environ, {}):
                try:
                    allowed_paths.add(instruction_file(client, args.home, environ))
                except ValueError:
                    # Another client's broken variable must not block this undo.
                    pass
        if target_path not in allowed_paths:
            print(f"ERROR: refusing unexpected personalization target: {target_path}", file=sys.stderr)
            setting = recorded_setting(args.client, args.home, state.get("destination"), str(target_path))
            if setting:
                print(f"HINT: if the setup went there, it was made with {setting}; undo it with that setting", file=sys.stderr)
            return 3
        if backup:
            backup_file = Path(str(backup))
            if backup_file.parent != target_path.parent or not backup_file.name.startswith(target_path.name + ".bak."):
                print(f"ERROR: refusing unexpected personalization backup: {backup_file}", file=sys.stderr)
                return 3
        expected_hash = profile.get("written_sha256")
        if target_path and (not isinstance(expected_hash, str) or not re.fullmatch(r"[0-9a-f]{64}", expected_hash)):
            print("ERROR: invalid personalization readback hash in rollback state", file=sys.stderr)
            return 3
    if target_path and backup and not target_path.exists():
        print(f"PRESERVED: personalization target is missing: {target_path}")
        return 3
    restored: bytes | None = None
    original: bytes | None = None
    # An instruction file edited since the setup stays exactly as it is. With its managed block
    # taken out by hand the undo is otherwise complete; with the block still there only the
    # links go. The backup is not needed either way.
    profile_changed = False
    block_gone = False
    if target_path and target_path.exists():
        current = read_or_report(target_path)
        if current is None:
            return 3
        expected_hash = profile.get("written_sha256")
        if hashlib.sha256(current).hexdigest() != expected_hash:
            block_gone = BLOCK_START not in current and BLOCK_END not in current
            profile_changed = not block_gone
    if target_path and target_path.exists() and not profile_changed and not block_gone:
        backup_file = Path(str(backup)) if backup else None
        if backup_file and (backup_file.is_symlink() or not backup_file.is_file()):
            print(f"ERROR: personalization backup must be a regular file: {backup_file}", file=sys.stderr)
            return 3
        original = read_or_report(backup_file) if backup_file else None
        if backup_file and original is None:
            return 3
        try:
            restored = restored_content(current, original)
        except ValueError:
            print(f"PRESERVED: managed block not found in {target_path}")
            return 3
    for name in created:
        target = destination / name
        if target.is_symlink():
            if not owns_link(target, name):
                print(f"PRESERVED: changed target {target}")
                return 3
        elif target.exists():
            print(f"PRESERVED: changed target {target}")
            return 3
    print(f"ROLLBACK_PLAN: remove={','.join(created) or 'none'}; personalization={target_path or 'none'}; backup={backup or 'none'}")
    if profile_changed:
        print_changed_profile(target_path, backup)
    else:
        if block_gone:
            print(f"INSTRUCTIONS_KEPT: {target_path} no longer holds the managed block, so it is left as it is")
        for directory in sorted(directories, key=lambda item: len(item.parts), reverse=True):
            print(f"ROLLBACK_FOLDER: {directory}: removed if it is still empty; the setup created it")
    if not args.apply:
        print(f"ROLLBACK_DRY_RUN: no files changed; pass --apply to reverse setup from {state_file}")
        return 0
    removed: list[str] = []
    for name in created:
        target = destination / name
        if owns_link(target, name):
            target.unlink()
            removed.append(name)
        elif target.exists() or target.is_symlink():
            print(f"PRESERVED: changed target {target}")

    if target_path and restored is not None:
        if original is None and not restored.strip():
            # The setup created the file, and it holds nothing else.
            target_path.unlink()
            if target_path.exists() or target_path.is_symlink():
                raise OSError(f"personalization file remained after rollback: {target_path}")
            print(f"REMOVED_MANAGED_BLOCK: {target_path}")
        else:
            mode = target_path.stat().st_mode & 0o777
            descriptor, temporary_name = tempfile.mkstemp(prefix=f".{target_path.name}.rollback.", dir=target_path.parent)
            temporary = Path(temporary_name)
            try:
                with os.fdopen(descriptor, "wb") as handle:
                    handle.write(restored)
                    handle.flush()
                    os.fsync(handle.fileno())
                temporary.chmod(mode)
                os.replace(temporary, target_path)
            except Exception:
                temporary.unlink(missing_ok=True)
                raise
            if target_path.read_bytes() != restored:
                raise OSError(f"personalization restore readback mismatch: {target_path}")
            if restored == original:
                print(f"RESTORED: {target_path} from {backup}")
            else:
                print(f"REMOVED_MANAGED_BLOCK: {target_path}; the rest of the file is kept as it is now")

    if state_file.lstat().st_ino != metadata.st_ino or state_file.lstat().st_dev != metadata.st_dev:
        print(f"ERROR: rollback state changed during rollback; preserved at {state_file}", file=sys.stderr)
        return 3
    for name in removed:
        target = destination / name
        if target.exists() or target.is_symlink():
            raise OSError(f"skill rollback readback mismatch: {target}")
    if profile_changed:
        # The record stays, with no links left in it, so that the block can still be taken
        # out once the record knows the file as it is now.
        save_state(state_file, {**state, "created_skills": []})
        print_changed_profile(target_path, backup)
        print(f"ROLLBACK_READBACK: removed={','.join(removed) or 'none'}; profile=preserved")
        return 3
    state_file.unlink()
    removed_dirs, kept_dirs, shared_dirs = remove_empty_directories(directories)
    for directory in removed_dirs:
        print(f"REMOVED_DIR: {directory}")
    for directory in kept_dirs:
        print(f"PRESERVED: {directory}: a folder above it is a link now, so it is left as it is")
    for directory in shared_dirs:
        print(f"PRESERVED: {directory}: another folder in the record leads to the same folder, so it is left as it is")
    if "created_dirs" not in state:
        # A record from before the folder list still gets its skills folder removed when empty.
        # One with the list says which folders the setup made, so no other is touched.
        try:
            destination.rmdir()
        except OSError:
            pass
    print(f"ROLLBACK_READBACK: removed={','.join(removed) or 'none'}; profile={'kept' if block_gone else 'restored'}")
    return 0


def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--repo-url", required=True, help="repository link supplied for setup context")
    parser.add_argument("--repo-path", type=Path, help="local checkout to inspect instead of contacting GitHub")
    parser.add_argument("--offline", action="store_true", help="skip network lookup and use only supplied context and local paths")
    parser.add_argument("--client", choices=("codex", "claude", "opencode", "copilot"), required=True)
    parser.add_argument("--work-profile", choices=PROFILE_CHOICES)
    parser.add_argument("--goal", help="task or outcome the setup should support")
    parser.add_argument("--tools", help="client and tools already in use")
    parser.add_argument("--existing-setup", help="existing skills or workflow setup")
    parser.add_argument("--selection", help="comma-separated recommendation IDs; required for --apply")
    parser.add_argument("--preferences", help="short owner-approved persistent setup preference")
    parser.add_argument(
        "--home",
        type=Path,
        help="profile home for the documented default locations; when omitted, "
        "CLAUDE_CONFIG_DIR, CODEX_HOME and XDG_CONFIG_HOME are honoured",
    )
    parser.add_argument("--dest", type=Path, help="skill target directory override for controlled setup/tests")
    parser.add_argument("--state-file", type=Path, help=argparse.SUPPRESS)
    parser.add_argument("--apply", action="store_true", help="apply the reviewed selected subset")
    parser.add_argument("--rollback", action="store_true", help="reverse the most recent managed setup")
    return parser.parse_args(argv)


def main(argv: list[str] | None = None) -> int:
    for stream in (sys.stdout, sys.stderr):
        try:
            # Piped output on Windows uses the ANSI code page, which cannot print every
            # character of a path or of the helpers' output.
            stream.reconfigure(encoding="utf-8", errors="replace")
        except (AttributeError, ValueError):
            pass
    args = parse_args(argv)
    try:
        # An explicit --home selects the documented defaults under it and ignores
        # the environment, which keeps isolated runs from touching a real profile.
        # The helpers run from the checkout, so a relative path would land inside it.
        args.home_explicit = args.home is not None
        args.environ = {} if args.home_explicit else os.environ
        if not args.home_explicit and os.name != "nt" and os.environ.get("HOME") == "":
            raise ValueError("HOME is set but empty")
        args.home = (
            Path(os.path.abspath(args.home.expanduser())) if args.home_explicit else Path.home()
        )
        if not args.home.is_absolute():
            raise ValueError(f"the home directory must be an absolute path, not {str(args.home)!r}")
        repo_url = safe_repo_url(args.repo_url)
        state = state_path(args.home, args.state_file, args.client)
        legacy_state = legacy_state_path(args.home, args.state_file)
        state_preimage: tuple[bytes, int] | None = None
        if args.rollback:
            if not state.exists() and not state.is_symlink() and legacy_state is not None and legacy_state.exists():
                legacy = read_state_file(legacy_state)
                legacy_owner = legacy_state_client(legacy, args.home)
                if legacy_owner is None:
                    print(f"ERROR: legacy rollback state has no provable client owner: {legacy_state}", file=sys.stderr)
                    return 3
                if legacy_owner != args.client:
                    print(f"ERROR: legacy rollback state belongs to {legacy_owner}; no {args.client} state exists", file=sys.stderr)
                    return 3
                state = legacy_state
            return rollback(args, state)
        previous: dict[str, object] = {}
        try:
            state_metadata = state.lstat()
        except FileNotFoundError:
            state_metadata = None
        if state_metadata is not None:
            previous = read_state_file(state)
            state_preimage = read_file_preimage(state)
        elif legacy_state is not None and legacy_state.exists():
            legacy = read_state_file(legacy_state)
            legacy_owner = legacy_state_client(legacy, args.home)
            if legacy_owner is None:
                raise ValueError(f"legacy setup state has no provable client owner; preserve and inspect it: {legacy_state}")
            if legacy_owner == args.client:
                previous = legacy
                print(f"LEGACY_STATE_MIGRATION: this {args.client} setup will be copied to {state}; original retained at {legacy_state}")
            else:
                print(f"LEGACY_STATE_PRESERVED: belongs to {legacy_owner}; current client={args.client}")
        ask_if_missing(args)
        if args.apply and not args.selection:
            raise ValueError("--apply requires an explicit --selection")
        if args.preferences and _sibling(PERSONALIZATION).preference_problem(args.preferences):
            raise ValueError(_sibling(PERSONALIZATION).preference_problem(args.preferences))
        if args.repo_path is not None:
            repository_context = inspect_local_repository(args.repo_path)
        elif args.offline:
            repository_context = RepositoryContext("OFFLINE")
        else:
            repository_context = inspect_github_repository(repo_url)
        destination = (
            args.dest or skills_destination(args.client, args.home, args.environ)
        ).expanduser().resolve()
        require_utf8(args.home, destination)
        if previous and Path(str(previous["destination"])).expanduser().resolve() != destination:
            setting = recorded_setting(
                args.client, args.home, previous["destination"], (previous.get("profile") or {}).get("target")
            )
            if setting is None:
                remedy = "undo it with --rollback --apply and the --home or --dest it was made with"
            elif setting.endswith(" unset"):
                # After an upgrade: 2.0.0 used the default place even when the variable was set.
                remedy = f"undo it with {setting} and --rollback --apply, or pass --home to keep that location"
            else:
                remedy = f"undo it with {setting} and --rollback --apply"
            raise ValueError(
                "existing setup state belongs to a different skills destination: "
                f"{previous['destination']} ({remedy}, then run this command again)"
            )
        before = installed_ids(destination)
        ranked = recommendations(
            args.work_profile,
            args.goal,
            args.tools,
            args.existing_setup,
            before,
            repository_context,
        )
        print(f"REPOSITORY: {display_repo_url(repo_url)}")
        print(f"REPOSITORY_SOURCE: {repository_context.status}; {repository_context.summary or 'repository content unavailable; using supplied answers only'}")
        if repository_context.status.startswith("LOCAL_") or repository_context.status.startswith("GITHUB_"):
            print("REPOSITORY_TRUST: README and instruction content treated as untrusted data; no scripts, hooks or installers executed.")
        for name, value in (("goal", args.goal), ("tools", args.tools), ("existing setup", args.existing_setup)):
            if len(value) > 240 or any(ord(char) < 0x20 for char in value):
                raise ValueError(f"{name} must be a single line of at most 240 characters")
        print(f"NEEDS: profile={args.work_profile}; goal={args.goal}; tools={args.tools}; existing={args.existing_setup}")
        capability = inspect_client_config(args.client, args.home, args.environ)
        print(
            "CLIENT_CAPABILITY_AUDIT: "
            f"client={args.client}; config_status={capability['config_status']}; "
            f"config_path={capability['config_path']}; "
            f"configured_main={capability['configured_main_model']}; "
            f"configured_effort={capability['configured_effort']}; "
            "active_profile=UNKNOWN; advertised_models=UNKNOWN; callable_workers=UNKNOWN; "
            "runtime_main_and_effort=UNKNOWN; runtime_context=UNKNOWN"
        )
        audit_skill = SKILLS_DIR / "nobrainer-auto-fine-tune" / "SKILL.md"
        if not audit_skill.is_file():
            raise ValueError(f"required Auto Fine Tune skill is missing: {audit_skill}")
        skill_text = audit_skill.read_text(encoding="utf-8")
        if "Preserve the owner's choice" not in skill_text or "Discover this client's actual capabilities" not in skill_text:
            raise ValueError(f"Auto Fine Tune audit instructions are incomplete: {audit_skill}")
        print("CAPABILITY_AUDIT: skill=nobrainer-auto-fine-tune; MAIN=UNKNOWN (preserved); effort=UNKNOWN; callable workers=UNKNOWN; runtime context=UNKNOWN; measurement=UNMEASURED")
        audit = SKILLS_DIR / "nobrainer-auto-fine-tune" / "references" / "capacity-audit.md"
        if not audit.is_file():
            raise ValueError(f"required Auto Fine Tune audit reference is missing: {audit}")
        audit_text = audit.read_text(encoding="utf-8")
        if "Separate four evidence layers" not in audit_text or "Client adapters" not in audit_text:
            raise ValueError(f"Auto Fine Tune capacity-audit reference is incomplete: {audit}")
        print(f"AUDIT_REFERENCE_READ: {audit.relative_to(ROOT)}")
        print("REQUIRED: 01,02 (nobrainer-tech-flow entrypoint plus standard read-only capability audit)")
        print("AVAILABLE OPTIONS:")
        for item in ITEMS:
            print(f"{item.id} {item.label}: {item.reason}")
        print("RECOMMENDATIONS (fit is a task-specific heuristic, not a benchmark):")
        for item, score, reason in ranked:
            conflict = target_state(destination, item)
            print(
                f"{item.id} {item.label} | Fit (heuristic)={score}/10 | {reason} | "
                f"Dependencies=01,02 | Target={conflict} | "
                f"Change scope=install {item.skill} and update selected client nobrainer-tech-flow instructions"
            )
        selection_text = args.selection
        if not selection_text and stdin_is_terminal():
            try:
                selection_text = input("Choose any recommendation IDs to install (comma-separated, blank to stop): ").strip()
            except EOFError:
                selection_text = ""
        chosen = selected_ids(selection_text)
        if not chosen:
            print("DRY_RUN: no files changed; choose IDs with --selection and review this plan.")
            return 0
        install_ids = sorted(set(chosen) | {"01", "02"})
        install_names = [BY_ID[item_id].skill for item_id in install_ids]
        new_skills = [name for name in install_names if BY_SKILL[name].id not in before]
        print(f"SELECTED: {','.join(chosen)}")
        print(f"INSTALL_SET: {','.join(install_ids)} (includes required 01,02)")
        # The installer runs once per skill, so it cannot see the whole set; judge the
        # cross-skill links here and hide its per-skill notes below.
        for name, other in _sibling(INSTALL_SKILLS).unmet_references(
            install_names, {skill: SKILLS_DIR / skill for skill in BY_SKILL}, destination
        ):
            print(
                f"NOTE: {name} links to {other}, which is not in this install set; "
                f"add selection {BY_SKILL[other].id} to include it"
            )
        personalization_client = args.client
        home_args = ["--home", str(args.home)] if args.home_explicit else []
        # The guided flow cannot set auto-update, session-restart or a wiki root, so
        # it must keep whatever an earlier run of the personalization script granted.
        profile_args = [sys.executable, str(PERSONALIZATION), "--client", personalization_client, *home_args, "--keep-options"]
        if args.preferences:
            profile_args.extend(("--preferences", args.preferences))
        code, profile_output = run(profile_args)
        if code:
            profile_failure_hint(profile_output)
            return code
        inherited = "INHERITS_CODEX:" in profile_output
        if inherited and args.preferences:
            personalization_client = "codex"
            profile_args = [sys.executable, str(PERSONALIZATION), "--client", personalization_client, *home_args, "--keep-options", "--preferences", args.preferences]
            code, profile_output = run(profile_args)
            if code:
                profile_failure_hint(profile_output)
                return code
        profile_target = instruction_file(personalization_client, args.home, args.environ)
        require_utf8(profile_target)
        old_profile = previous.get("profile", {})
        if old_profile.get("target") and Path(str(old_profile["target"])) != profile_target:
            setting = recorded_setting(args.client, args.home, previous.get("destination"), old_profile["target"])
            with_setting = f" with {setting} and" if setting else " with"
            raise ValueError(
                "existing setup state belongs to a different personalization target: "
                f"{old_profile['target']} (an earlier run wrote its instructions there; undo that "
                f"setup{with_setting} --rollback --apply, then run this command again)"
            )
        if not args.apply:
            return 0
        # The folders this run may create, so that an undo can remove the ones it made.
        # The record's own folder counts: a home that does not exist yet is made for it alone
        # when a client variable puts the skills and the instructions elsewhere.
        new_directories = missing_directories(destination, profile_target.parent, state.parent)
        # They are recorded as they really are, links resolved, and a path the record cannot
        # hold stops the run here, before anything is written.
        require_utf8(*(physical_path(path) for path in new_directories))
        # Inspect every target before writing any of them. This keeps a later
        # conflict from leaving a partial selection installed.
        for name in install_names:
            command = [sys.executable, str(ROOT / "scripts" / "install_skills.py"), "--client", args.client, "--dest", str(destination), "--skill", name, *recorded_retired_args(previous)]
            code, _ = run(command, hide=("NOTE: ",))
            if code:
                return code
        # The record's folder is made before anything else is written: when it cannot be made,
        # the run stops here instead of leaving links and a block that no record describes.
        state.parent.mkdir(parents=True, exist_ok=True)
        profile_preimage = read_file_preimage(profile_target)
        if args.apply:
            for name in install_names:
                command = [sys.executable, str(ROOT / "scripts" / "install_skills.py"), "--client", args.client, "--dest", str(destination), "--skill", name, "--apply", *recorded_retired_args(previous)]
                code, _ = run(command, hide=("NOTE: ",))
                if code:
                    remove_exact_created_links(destination, new_skills)
                    return code
        if not inherited or args.preferences:
            profile_args.append("--apply")
            code, profile_output = run(profile_args)
            if code:
                # A profile write can race after preflight. Reverse only the
                # exact new symlinks from this invocation before returning.
                remove_exact_created_links(destination, new_skills)
                return code
        backup_match = re.search(r"^BACKUP: (.+)$", profile_output, re.MULTILINE)
        if inherited and not args.preferences:
            profile = {}
        else:
            if old_profile.get("target"):
                # Undo puts the block back as it was before the first setup, so a later
                # run keeps the first backup (or its absence), not the one it just made.
                recorded_backup = old_profile.get("backup")
            elif backup_match:
                recorded_backup = backup_match.group(1)
            elif profile_preimage is not None:
                # The file already held this exact block, so nothing was written and no
                # backup was made: keep its preimage so undo leaves that block in place.
                recorded_backup = str(save_preimage(profile_target, profile_preimage))
            else:
                recorded_backup = None
            profile = {
                "target": str(profile_target),
                "backup": recorded_backup,
                "written_sha256": hashlib.sha256(profile_target.read_bytes()).hexdigest(),
            }
        # A retired module's link is gone once retired; then the record drops its name.
        kept = {name for name in previous.get("created_skills", []) if name in BY_SKILL or owns_link(destination / name, name)}
        created = sorted(kept | {name for name in new_skills if (destination / name).is_symlink()})
        next_state = {"destination": str(destination), "created_skills": created, "profile": profile}
        # Folders an earlier run made stay in the record as recorded, links already resolved
        # then. Resolving them again would follow a link put in their place since, and hand the
        # folder it points to over to the undo. A record from before this field existed has
        # none: its folders may have been there already, so none is claimed. A folder this run
        # made is written the way it really is, links resolved, so that runs from different
        # installers and spellings of the same path list it once.
        previous_dirs = {str(path) for path in recorded_directories(previous, state)} if previous else set()
        made_dirs = {str(physical_path(path)) for path in new_directories if path.is_dir() and not path.is_symlink()}
        created_dirs = sorted(previous_dirs | made_dirs)
        # A record that has no folder list is from an earlier version. It keeps that form until
        # a run makes a folder, so that its undo follows the rule it was made under.
        if created_dirs or not previous or "created_dirs" in previous:
            next_state["created_dirs"] = created_dirs
        # The retired links the record lists go last, after every other write, and come back
        # if the record cannot be saved.
        retired_claims = []
        try:
            retired_claims = claim_retired_links(destination, previous)
            retired_names = {target.name for target, _, _ in retired_claims}
            next_state["created_skills"] = [name for name in created if name not in retired_names]
            save_state(state, next_state)
        except (OSError, RuntimeError, TypeError, ValueError) as exc:
            rollback_errors: list[str] = []
            if isinstance(exc, _sibling(INSTALL_SKILLS).ClaimRestoreError):
                rollback_errors.append(str(exc))
            if profile.get("target") and "APPLIED: " in profile_output:
                restore_preimage = profile_preimage
                if backup_match:
                    backup_path = Path(backup_match.group(1))
                    try:
                        backup_metadata = backup_path.lstat()
                        if (
                            backup_path.parent != profile_target.parent
                            or not backup_path.name.startswith(profile_target.name + ".bak.")
                            or stat.S_ISLNK(backup_metadata.st_mode)
                            or not stat.S_ISREG(backup_metadata.st_mode)
                        ):
                            raise ValueError("personalization backup is not an exact regular sibling")
                        restore_preimage = (
                            backup_path.read_bytes(),
                            stat.S_IMODE(backup_metadata.st_mode),
                        )
                    except (OSError, ValueError) as rollback_exc:
                        rollback_errors.append(f"personalization preimage unavailable: {rollback_exc}")
                if not rollback_errors:
                    try:
                        profile_restored = restore_file_preimage(
                            profile_target,
                            str(profile["written_sha256"]),
                            restore_preimage,
                        )
                    except (OSError, ValueError) as rollback_exc:
                        profile_restored = False
                        rollback_errors.append(f"personalization rollback failed: {rollback_exc}")
                    if not profile_restored:
                        rollback_errors.append(f"personalization changed during rollback: {profile_target}")

            try:
                remove_exact_created_links(destination, new_skills)
            except OSError as rollback_exc:
                rollback_errors.append(f"skill rollback failed: {rollback_exc}")
            rollback_errors.extend(restore_retired_links(retired_claims))
            remaining_links = [
                name
                for name in new_skills
                if (destination / name).exists() or (destination / name).is_symlink()
            ]
            if remaining_links:
                rollback_errors.append("new skill targets changed and were preserved: " + ",".join(remaining_links))

            next_state_bytes = (json.dumps(next_state, indent=2) + "\n").encode("utf-8")
            try:
                state_restored = restore_file_preimage(
                    state,
                    hashlib.sha256(next_state_bytes).hexdigest(),
                    state_preimage,
                )
            except (OSError, ValueError) as rollback_exc:
                state_restored = False
                rollback_errors.append(f"setup state rollback failed: {rollback_exc}")
            if not state_restored:
                rollback_errors.append(f"setup state changed during rollback: {state}")

            detail = f"{exc}; rollback incomplete: {'; '.join(rollback_errors)}" if rollback_errors else f"{exc}; applied changes were rolled back and read back"
            print(f"ERROR: setup state save failed: {detail}", file=sys.stderr)
            return 3 if rollback_errors else 2
        commit_retired_links(retired_claims)
        after = installed_ids(destination)
        actual_added = sorted(after - before)
        expected_added = sorted(BY_SKILL[name].id for name in new_skills)
        if actual_added != expected_added:
            print(f"ERROR: install readback mismatch expected={expected_added} actual={actual_added}", file=sys.stderr)
            return 3
        for item_id in set(BY_ID) - set(install_ids) - before:
            if (destination / BY_ID[item_id].skill).exists() or (destination / BY_ID[item_id].skill).is_symlink():
                print(f"ERROR: unselected item was installed: {item_id}", file=sys.stderr)
                return 3
        print(f"READBACK: installed={','.join(sorted(after))}; rejected/unselected remain absent unless previously present")
        if profile.get("target"):
            print(f"PREFERENCE_READBACK: {profile_target}; hash={profile['written_sha256']}")
        else:
            print("PREFERENCE_READBACK: inherited from Codex global instructions; no duplicate block written")
        print(f"ROLLBACK_STATE: {state}")
        print("CAPABILITY_AUDIT_LIMIT: host runtime evidence remains UNKNOWN; run the loaded skill in the active client before recommending route changes.")
        return 0
    except InputRequired as exc:
        print(f"INPUT_REQUIRED: {exc}", file=sys.stderr)
        return 2
    except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        return 2


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