"""``hermes plugins`` CLI subcommand — install, update, remove, and list plugins.

Facade: shared primitives (errors, console/config helpers, manifest reading, discovery, enable/disable
selection) and the dispatch table live here; each verb family lives in a ``plugins_cmd_<topic>.py``
sibling (install, update, remove, git, capabilities, toggle, listing, catalog)."""

from __future__ import annotations

import functools
import logging
import os
import shutil
import sys
from pathlib import Path
from typing import Any, NoReturn, Optional

from hermes_constants import get_hermes_home
from hermes_cli.config import cfg_get
from hermes_cli.plugin_capabilities import _child_dict
# Tests patch these two on the facade; the install/remove siblings read them through it.
from hermes_cli.secret_prompt import masked_secret_prompt  # noqa: F401
from utils import rmtree_readonly  # noqa: F401

# Topical siblings. The facade re-exports what other modules, tests and the old updater import from
# ``hermes_cli.plugins_cmd``; sibling bodies read those names back through the facade at call time.
from hermes_cli.plugins_cmd_capabilities import (  # noqa: F401
    _declared_capabilities_for_key, _declared_capabilities_from_manifest, _resolve_tool_override_grant,
    _run_capability_consent, cmd_capabilities,
)
from hermes_cli.plugins_cmd_git import (  # noqa: F401
    _EXACT_COMMIT_RE, _canonical_source, _checkout_exact_revision, _clone_plugin_repo, _git_head_revision,
    _git_or_raise, _git_pull_plugin_dir, _git_resolve_commit, _normalize_exact_revision, _pin_annotation,
    _read_install_metadata, _run_plugin_git, _safe_git_error, _scrub_cloned_origin, _update_install_record,
    _write_install_metadata, pinned_revision,
)
from hermes_cli.plugins_cmd_install import (  # noqa: F401
    _check_manifest_version, _consent_python_deps, _display_after_install, _install_plugin_core,
    _install_plugin_python_deps, _prompt_plugin_env_vars, _python_dependency_summary,
    _read_manifest_for_install, cmd_install, dashboard_install_plugin,
)
from hermes_cli.plugins_cmd_listing import (  # noqa: F401
    _filter_plugin_entries, cmd_list, cmd_show,
)
from hermes_cli.plugins_cmd_remove import (  # noqa: F401
    _remove_plugin_core, cmd_remove, dashboard_remove_user_plugin,
)
from hermes_cli.plugins_cmd_toggle import (  # noqa: F401
    _discover_context_engines, _persist_plugin_selection, _provider_categories, _run_composite_fallback,
    cmd_toggle,
)
from hermes_cli.plugins_cmd_update import (  # noqa: F401
    _clear_plugin_bytecode, cmd_adopt, cmd_check_updates, cmd_trust_update_url, cmd_update,
    dashboard_update_user_plugin,
)

logger = logging.getLogger(__name__)
_DEFAULT_CLONE_TIMEOUT_SECONDS = 300
_MAX_CLONE_TIMEOUT_SECONDS = 3600
_CLONE_TIMEOUT_HINT = "On a slow connection, raise plugins.clone_timeout_seconds in config.yaml."


@functools.lru_cache(maxsize=1)
def _resolve_git_executable() -> Optional[str]:
    """Resolve a git binary for subprocess use when ``PATH`` may be minimal."""
    found = shutil.which("git")
    if found:
        return found
    if os.name == "nt":
        roots = [
            os.environ.get("ProgramFiles", r"C:\Program Files"),
            os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"),
        ]
        local = os.environ.get("LOCALAPPDATA", "")
        if local:
            roots.append(os.path.join(local, "Programs"))
        candidates = [os.path.join(r, "Git", sub, "git.exe") for r in roots for sub in ("cmd", "bin")]
    else:
        candidates = ["/usr/bin/git", "/usr/local/bin/git", "/bin/git"]
    return next((c for c in candidates if c and os.path.isfile(c)), None)


class PluginOperationError(Exception):
    """Recoverable plugin install/update failure (CLI exits; HTTP maps to 4xx)."""


class PluginScanBlocked(PluginOperationError):
    """Plugin failed the security scan and was not installed."""

    def __init__(self, message: str, scan_result=None):
        super().__init__(message)
        self.scan_result = scan_result


def _console():
    """A fresh Rich console (rich is imported lazily)."""
    from rich.console import Console
    return Console()


def _table(columns, **kwargs):
    """A Rich ``Table(**kwargs)`` with ``(header, style)`` *columns* added in order."""
    from rich.table import Table
    table = Table(**kwargs)
    for header, style in columns:
        table.add_column(header, style=style)
    return table


def _is_tty() -> bool:
    return sys.stdin.isatty() and sys.stdout.isatty()


def _fail(console, message: str) -> NoReturn:
    """Print *message* and exit 1."""
    console.print(message)
    sys.exit(1)


def _ask_yes(prompt: str, reader=input) -> bool:
    """One y/N question; EOF / Ctrl-C count as "no"."""
    try:
        answer = reader(prompt).strip().lower()
    except (EOFError, KeyboardInterrupt):
        return False
    return answer in {"y", "yes"}


def _config_value(*keys: str, default: Any) -> Any:
    """Read ``keys`` from config.yaml; *default* on a missing key or any load failure."""
    try:
        from hermes_cli.config import load_config
        return cfg_get(load_config(), *keys, default=default)
    except Exception:
        return default


def _clone_timeout_seconds() -> int:
    """Deadline for plugin clone and pinned fetch, scoped to the active profile."""
    value = _config_value("plugins", "clone_timeout_seconds", default=_DEFAULT_CLONE_TIMEOUT_SECONDS)
    if isinstance(value, bool) or not isinstance(value, int) or value < 1:
        logger.warning("plugins.clone_timeout_seconds must be a positive integer; using %ss",
                       _DEFAULT_CLONE_TIMEOUT_SECONDS)
        return _DEFAULT_CLONE_TIMEOUT_SECONDS
    if value > _MAX_CLONE_TIMEOUT_SECONDS:
        logger.warning("plugins.clone_timeout_seconds exceeds %ss; clamping", _MAX_CLONE_TIMEOUT_SECONDS)
        return _MAX_CLONE_TIMEOUT_SECONDS
    return value


def _config_name_set(*keys: str) -> set:
    """A list-valued config key as a set (empty on any failure or non-list)."""
    value = _config_value(*keys, default=[])
    return set(value) if isinstance(value, list) else set()


def _config_str(*keys: str, default: str) -> str:
    """A string config key; empty/missing/failed reads coerce to *default*."""
    return _config_value(*keys, default=default) or default


def _write_config_value(section: str, key: str, value: Any) -> None:
    """Persist ``config[section][key] = value`` to config.yaml (creating the section)."""
    from hermes_cli.config import load_config, save_config
    config = load_config()
    config.setdefault(section, {})[key] = value
    save_config(config)


def _scan_on_install_enabled() -> bool:
    """Install/update-time security scanning; on by default, off via ``plugins.scan_on_install: false``."""
    return bool(_config_value("plugins", "scan_on_install", default=True))


def _scan_plugin_tree(plugin_dir: Path, identifier: str, *, force: bool, scan_decision_cb=None,
                      reviewed_pin: bool = False):
    """Scan *plugin_dir* and enforce the install policy.

    Verdicts: safe → proceed; caution → needs confirmation (``force=True`` or a truthy
    ``scan_decision_cb(result)``); dangerous → always blocked (:class:`PluginScanBlocked`).
    *reviewed_pin* marks a tree checked out at a curated-catalog sha: that exact tree passed
    the same scanner at admission with a human reading the caution findings, so caution is
    accepted without a prompt (the Desktop has none). Dangerous still blocks — a signature
    added after review is exactly the case the backstop exists for.
    Returns the ScanResult, or None when scanning is disabled.
    """
    if not _scan_on_install_enabled():
        return None
    from tools.plugin_guard import format_scan_report, scan_plugin, should_allow_plugin_install
    result = scan_plugin(plugin_dir, source=identifier)
    allowed, reason = should_allow_plugin_install(result, force=force)
    if allowed is None and reviewed_pin:
        allowed, reason = True, "Caution verdict accepted: reviewed catalog pin"

    if allowed is None and scan_decision_cb is not None:
        try:
            if scan_decision_cb(result):
                allowed = True
                reason = "Caution verdict accepted by user"
        except Exception:
            logger.exception("plugin scan decision callback failed")

    if allowed is not True:
        raise PluginScanBlocked(
            f"Security scan blocked plugin install: {reason}\n\n"
            f"{format_scan_report(result)}\n"
            "Review the findings above. Install only plugins from sources "
            "you trust. (Scanning can be configured via "
            "plugins.scan_on_install in config.yaml.)",
            scan_result=result)
    logger.info("plugin scan passed for %s: %s", plugin_dir.name, reason)
    return result


def _preserved_files_note(exc: PluginScanBlocked, merged: list[str]) -> str:
    """Scan-block text for a tree that also holds user files preserved from the installed copy."""
    preserved = set(merged)
    findings = exc.scan_result.findings if exc.scan_result is not None else ()
    hits = sorted({f.file for f in findings if f.file in preserved})
    if hits:
        note = ("These findings come from user files preserved from the installed copy: "
                f"{', '.join(hits)}. Move or remove them and retry the update.")
    else:
        note = "The scanned tree included user files preserved from the installed copy."
    return f"{exc}\n\n{note}"


def _scan_merged_tree(plugin_dir: Path, identifier: str, merged: Optional[list[str]], **kwargs):
    """:func:`_scan_plugin_tree` for a candidate that may hold carried user files (*merged*).

    A block then names the findings that sit in those files, so user data does not read as a
    malicious upstream revision; the original block stays chained as the cause.
    """
    try:
        return _scan_plugin_tree(plugin_dir, identifier, **kwargs)
    except PluginScanBlocked as exc:
        if not merged:
            raise
        raise PluginScanBlocked(_preserved_files_note(exc, merged), scan_result=exc.scan_result) from exc


def _plugins_dir() -> Path:
    """Return the user plugins directory, creating it if needed."""
    plugins = get_hermes_home() / "plugins"
    plugins.mkdir(parents=True, exist_ok=True)
    return plugins


def _sanitize_plugin_name(name: str, plugins_dir: Path, *, allow_subdir: bool = False) -> Path:
    """Validate a plugin name and return the safe target path inside *plugins_dir*.

    Raises ``ValueError`` on traversal or a target outside the plugins directory. ``allow_subdir``
    permits forward slashes so category keys like ``observability/langfuse`` can be looked up
    (``..`` and backslashes stay rejected); install paths keep ``False`` — a clone lands top-level.
    """
    if allow_subdir and name:
        name = name.strip("/")
    if not name:
        raise ValueError("Plugin name must not be empty.")
    if name in {".", ".."}:
        raise ValueError(f"Invalid plugin name '{name}': must not reference the plugins directory itself.")
    for bad in ("\\", "..") if allow_subdir else ("/", "\\", ".."):
        if bad in name:
            raise ValueError(f"Invalid plugin name '{name}': must not contain '{bad}'.")

    target = (plugins_dir / name).resolve()
    plugins_resolved = plugins_dir.resolve()
    if target == plugins_resolved:
        raise ValueError(f"Invalid plugin name '{name}': resolves to the plugins directory itself.")
    if plugins_resolved not in target.parents:
        raise ValueError(f"Invalid plugin name '{name}': resolves outside the plugins directory.")
    return target


_GITHUB_BROWSER_SEGMENTS = {
    "actions", "blob", "commit", "commits", "issues", "pull", "pulls", "releases", "tree", "wiki",
}
_URL_SCHEMES = ("https://", "http://", "git@", "ssh://", "file://")


def _resolve_git_url(identifier: str) -> tuple[str, Optional[str]]:
    """Turn an identifier into a cloneable Git URL and optional subdirectory.

    ``http://`` and ``file://`` are accepted but trigger a security warning at install time.
    """
    if identifier.startswith(_URL_SCHEMES):
        if identifier.startswith("https://github.com/"):
            path = identifier[len("https://github.com/") :]
            path = path.split("?", 1)[0].split("#", 1)[0].strip("/")
            parts = path.split("/")
            if len(parts) >= 3 and all(parts[:2]) and parts[2] in _GITHUB_BROWSER_SEGMENTS:
                repo = parts[1].removesuffix(".git")
                subdir = None
                if parts[2] == "tree" and len(parts) >= 5:
                    subdir = "/".join(p for p in parts[4:] if p).strip("/") or None
                return f"https://github.com/{parts[0]}/{repo}.git", subdir

        # Explicit ``#subdir`` fragment — unambiguous for any scheme.
        if "#" in identifier:
            git_url, _, frag = identifier.partition("#")
            return git_url, (frag.strip("/") or None)
        # Natural ``.git/`` boundary (GitHub-style URLs).
        git_url, marker, subdir = identifier.partition(".git/")
        if marker:
            return git_url + ".git", (subdir.strip("/") or None)
        return identifier, None

    # owner/repo[/subdir...] or owner/repo#subdir shorthand (the catalog spells subdirs with ``#``).
    identifier, _, fragment = identifier.partition("#")
    parts = [p for p in identifier.strip("/").split("/") if p]
    if len(parts) >= 2:
        subdir = "/".join([*parts[2:], *fragment.split("/")]).strip("/")
        return f"https://github.com/{parts[0]}/{parts[1]}.git", (subdir or None)
    raise ValueError(
        f"Invalid plugin identifier: '{identifier}'. "
        "Use a Git URL or 'owner/repo' shorthand (optionally with a subdirectory: "
        "'owner/repo/path/to/plugin').")


def _resolve_subdir_within(clone_root: Path, subdir: str) -> Path:
    """Resolve ``subdir`` inside ``clone_root``; ``..``, absolute paths and symlinks may not
    escape the clone. Raises ``PluginOperationError`` if it escapes, is missing, or is a file."""
    clone_root = clone_root.resolve()
    candidate = (clone_root / subdir).resolve()
    if candidate != clone_root and clone_root not in candidate.parents:
        raise PluginOperationError(f"Plugin subdirectory '{subdir}' escapes the repository.")
    if not candidate.exists():
        raise PluginOperationError(f"Plugin subdirectory '{subdir}' does not exist in the repository.")
    if not candidate.is_dir():
        raise PluginOperationError(f"Plugin subdirectory '{subdir}' is not a directory.")
    return candidate


def _repo_name_from_url(url: str) -> str:
    """Repo name from a Git URL (last path component; ssh-style ``git@host:repo`` splits on ':')."""
    name = url.rstrip("/").removesuffix(".git").rsplit("/", 1)[-1]
    if ":" in name:
        name = name.rsplit(":", 1)[-1].rsplit("/", 1)[-1]
    return name


def _native_manifest_file(plugin_dir: Path) -> Optional[Path]:
    """``plugin.yaml`` (or ``plugin.yml``) under *plugin_dir*, or None when neither exists."""
    from pm.plugin_declarations import native_manifest_file

    try:
        return native_manifest_file(plugin_dir)
    except ValueError as exc:
        raise PluginOperationError(str(exc)) from exc


def _has_portable_manifest(plugin_dir: Path) -> bool:
    """True when ``plugin.json`` exists (or is a symlink, even dangling) under *plugin_dir*."""
    portable_file = plugin_dir / "plugin.json"
    return portable_file.exists() or portable_file.is_symlink()


def _load_yaml_manifest(manifest_file: Path):
    """``yaml.safe_load`` of *manifest_file* (``{}`` when empty); raises on any read/parse error."""
    from pm.plugin_declarations import read_native_manifest

    return read_native_manifest(manifest_file)


def _read_manifest(plugin_dir: Path) -> dict:
    """Read a native or portable manifest, preferring native YAML."""
    manifest_file = _native_manifest_file(plugin_dir)
    if manifest_file is None:
        if not _has_portable_manifest(plugin_dir):
            return {}
        try:
            from hermes_cli.agent_plugins import read_agent_plugin_manifest
            return read_agent_plugin_manifest(plugin_dir)[0]
        except Exception as e:
            logger.warning("Failed to read plugin.json in %s: %s", plugin_dir, e)
            return {}
    try:
        return _load_yaml_manifest(manifest_file)
    except Exception as e:
        logger.warning("Failed to read plugin.yaml in %s: %s", plugin_dir, e)
        return {}


def _looks_like_plugin_dir(target: Path) -> bool:
    """True when *target* has a native/portable manifest or a package ``__init__.py``."""
    return (
        _native_manifest_file(target) is not None
        or (target / "plugin.json").exists()
        or (target / "__init__.py").exists())


def _copy_example_files(plugin_dir: Path, console) -> None:
    """Copy ``*.example`` files to their real names (``config.yaml.example`` -> ``config.yaml``),
    never overwriting an existing file so reinstall keeps user config."""
    for example_file in plugin_dir.glob("*.example"):
        real_name = example_file.stem
        real_path = plugin_dir / real_name
        if real_path.exists():
            continue
        try:
            shutil.copy2(example_file, real_path)
            console.print(f"[dim]  Created {real_name} from {example_file.name}[/dim]")
        except OSError as e:
            console.print(f"[yellow]Warning:[/yellow] Failed to copy {example_file.name}: {e}")


def _missing_env_specs(manifest: dict) -> list[dict]:
    """``requires_env`` entries (plain names or ``{name, description, url, secret}`` dicts,
    normalised to dicts; nameless entries dropped) whose variable is unset in ``~/.hermes/.env``."""
    env_specs = [
        {"name": entry} if isinstance(entry, str) else entry
        for entry in manifest.get("requires_env") or []
        if isinstance(entry, str) or (isinstance(entry, dict) and entry.get("name"))
    ]
    if not env_specs:
        return []
    from hermes_cli.config import get_env_value
    return [s for s in env_specs if not get_env_value(s["name"])]


def _clone_failure_message(git_url: str, git_error: str) -> str:
    """Plain-words clone failure: what to check (address, network, private repo), raw git text last.

    The text reaches ``_fail`` -> Rich ``console.print``: escape the git output so ``[...]`` in it is
    not parsed as markup."""
    from rich.markup import escape
    return (f"Could not download the plugin from {git_url}. Check the address (browse the catalog "
            "with `hermes plugins search`), check your internet connection, or, if the repository "
            "is private, sign in first with `gh auth login` (or set GITHUB_TOKEN in your .env).\n"
            f"Details: {escape(git_error.strip())}")


def _require_installed_plugin(name: str, plugins_dir: Path, console) -> Path:
    """The plugin path if it exists; else exit 1 (invalid name, or a listing of installed plugins)."""
    try:
        target = _sanitize_plugin_name(name, plugins_dir, allow_subdir=True)
    except ValueError as e:
        _fail(console, f"[red]Error:[/red] {e}")
    if not target.exists():
        _fail(console, _unknown_plugin_message(name, downloaded_only=True))
    return target


def _unknown_plugin_message(name: str, *, downloaded_only: bool = False) -> str:
    """``No plugin named ...`` with the exact-name rule and the two commands that resolve it."""
    scope = (" This command only works on downloaded plugins; bundled ones can only be enabled or disabled."
             if downloaded_only else " Bundled plugins can only be enabled or disabled.")
    return (f"[red]No plugin named '{name}'.[/red] Run `hermes plugins list` to see the exact names "
            f"(nested plugins use their full key, e.g. web/firecrawl).{scope} "
            "To add one: `hermes plugins install <owner/repo>`.")


# ``plugins.disabled`` is an explicit deny-list that wins over the ``plugins.enabled`` allow-list.
_get_disabled_set = functools.partial(_config_name_set, "plugins", "disabled")
_get_enabled_set = functools.partial(_config_name_set, "plugins", "enabled")


def _save_enabled_set(enabled: set) -> None:
    """Frozen old-updater import: never resurrect a raw plugin-selection write."""
    from hermes_cli._old_updater import stop_for_relaunch

    stop_for_relaunch()


def _plugin_selection_version() -> str:
    from hermes_cli.runtime_state import _digest
    return _digest(get_hermes_home() / "config.yaml") or "missing"


def _admit_and_save_plugin_sets(
    enabled: set, disabled: set, *, extra_dirs=(), console=None, action: str = "enable", expected_config=None,
    plugin: Optional[str] = None,
) -> None:
    """ONE admission authority for proposed enabled/disabled sets (C13):
    the candidate union is resolved against the ACTIVE environment and
    the config commits inside the same worker-owned PM transaction — a refusal or a config-write failure
    publishes nothing: previous config bytes AND previous environment
    stay exactly in place. Raises :class:`AdmissionRefused` (UI callers
    catch and surface it — admission never auto-disables to fit); a resolver
    conflict raises its :class:`DependencyConflict` subclass naming *plugin*."""
    from rich.markup import escape

    from hermes_cli.plugins_admission import AdmissionRefused, DependencyConflict, admit_plugin_set_change

    try:
        admit_plugin_set_change(
            enabled, disabled, active_plugins_dir=_plugins_dir(), extra_dirs=extra_dirs, expected_config=expected_config,
            plugin=plugin,
        )
    except DependencyConflict as exc:
        # `hermes pm install` cannot fix a conflict, so the retry hint below would mislead here.
        if console is not None:
            console.print(f"[red]✗[/red] {escape(str(exc))}")
            console.print("[dim]config.yaml and the active environment are unchanged.[/dim]")
        raise
    except AdmissionRefused as exc:
        if console is not None:
            console.print(f"[red]✗[/red] {action} refused: {exc}")
            console.print(
                "[dim]config.yaml and the active environment are unchanged. "
                "Run `hermes pm install` to resolve dependencies, then retry.[/dim]"
            )
        raise



_BASIC_AUTH_PLUGIN_KEYS = frozenset({"basic", "dashboard_auth/basic"})


def ensure_basic_auth_plugin_enabled_in_config(cfg: dict) -> bool:
    """Drop the bundled basic dashboard-auth plugin from ``plugins.disabled`` in *cfg*.

    ``hermes setup`` / ``hermes plugins disable basic`` can park it there while
    ``dashboard.basic_auth`` is configured, and password auth then silently fails.
    Returns True when modified.
    """
    plugins_cfg = cfg.get("plugins")
    disabled = plugins_cfg.get("disabled") if isinstance(plugins_cfg, dict) else None
    if not isinstance(disabled, list) or not (set(disabled) & _BASIC_AUTH_PLUGIN_KEYS):
        return False
    plugins_cfg["disabled"] = sorted(set(disabled) - _BASIC_AUTH_PLUGIN_KEYS)
    return True


def _discard_key_and_leaf(names: set, key: str) -> None:
    """Drop *key* and its bare leaf (``observability/langfuse`` -> ``langfuse``) from *names*, so a
    stale legacy bare-name entry can't keep vetoing the canonical key."""
    names.discard(key)
    names.discard(key.split("/")[-1])


def _plugin_aliases(key: str, entries: Optional[list] = None) -> set:
    """Every spelling a config list may hold for *key*: the key, its bare leaf and the manifest name.
    The loader matches BOTH the canonical key (``web/firecrawl``) and the manifest name
    (``web-firecrawl``), so a stale entry under any form vetoes an enable ("explicit disable wins").
    Pass *entries* to reuse one :func:`_discover_all_plugins` scan across several keys."""
    names = {key, key.split("/")[-1]}
    names.update(e[0] for e in (_discover_all_plugins() if entries is None else entries) if e[5] == key)
    return names


def _activate_key(key: str, *, enable: bool, console=None) -> bool:
    """Transactionally persist canonical *key*, purging aliases from the opposing list.

    False when the lists already say so (nothing written). PM owns the selection and dependency
    publication together, so every CLI/dashboard activation surface goes through the same admission
    transaction instead of writing ``config.yaml`` directly."""
    enabled, disabled = _get_enabled_set(), _get_disabled_set()
    aliases = _plugin_aliases(key)
    target, other = (enabled, disabled) if enable else (disabled, enabled)
    if key in target and not (aliases & other):
        return False
    _set_plugin_enabled(key, enable=enable, aliases=aliases, console=console)
    return True


def _forget_plugin_config(aliases: set) -> dict[str, Any]:
    """Drop every trace of a removed plugin (its :func:`_plugin_aliases`, taken BEFORE the tree went)
    from config.yaml: allow/deny-list entries, ``plugins.entries.<id>`` grants and a ``memory.provider``
    selection naming it. A later reinstall under the same name must start from the "Enable now?"
    decision, not inherit a stale enable or grant (#54336); a dangling ``memory.provider`` would make
    the next agent init re-clone the plugin from the catalog, silently undoing the uninstall.
    Returns ``{"cleared_memory_provider": True}`` when the selection was reset."""
    from hermes_cli.config import load_config, save_config
    config = load_config()
    changed = False
    result: dict[str, Any] = {}
    plugins_cfg = config.get("plugins")
    if isinstance(plugins_cfg, dict):
        for list_key in ("enabled", "disabled"):
            names = plugins_cfg.get(list_key)
            if isinstance(names, list) and aliases & set(names):
                plugins_cfg[list_key] = sorted(set(names) - aliases)
                changed = True
        entries = plugins_cfg.get("entries")
        if isinstance(entries, dict) and aliases & set(entries):
            for alias in aliases & set(entries):
                del entries[alias]
            changed = True
    memory_cfg = config.get("memory")
    if isinstance(memory_cfg, dict) and str(memory_cfg.get("provider") or "").strip() in aliases:
        memory_cfg["provider"] = ""
        changed, result = True, {"cleared_memory_provider": True}
    if changed:
        save_config(config)
    return result


def _set_plugin_enabled(name: str, *, enable: bool, aliases=(), console=None) -> None:
    """Submit the command's delta with the version of the selection it read."""
    from pm.plugins_state import read_home_selection

    expected_config = _plugin_selection_version()
    config = read_home_selection(get_hermes_home()) or {}
    plugins = config.get("plugins") or {}
    enabled = set(plugins.get("enabled") or ())
    disabled = set(plugins.get("disabled") or ())
    _apply_activation(enabled, disabled, name, aliases, enable=enable)
    _admit_and_save_plugin_sets(enabled, disabled, console=console,
                               action=f"{'Enable' if enable else 'Disable'} '{name}'",
                               expected_config=expected_config, plugin=name if enable else None)


def _apply_activation(enabled: set, disabled: set, key: str, aliases, *, enable: bool) -> None:
    """Add canonical *key* to the target list and purge it, its bare leaf and *aliases* from the other."""
    removed = disabled if enable else enabled
    _discard_key_and_leaf(removed, key)
    removed.difference_update(aliases)
    (enabled if enable else disabled).add(key)


def _resolve_plugin_key(name: str) -> Optional[str]:
    """Canonical registry key for a manifest name / directory name / path key, or ``None``.
    The single normalization point so enable/disable write the key ``PluginManager`` gates on."""
    resolved = _resolve_plugin_key_and_source(name)
    return resolved[0] if resolved else None


def _find_plugin_entry(name: str) -> Optional[tuple]:
    """First discovered ``(name, version, description, source, dir_path, key)`` entry whose
    manifest name or canonical key equals *name*."""
    return next((entry for entry in _discover_all_plugins() if name in (entry[0], entry[5])), None)


def _resolve_plugin_key_and_source(name: str) -> Optional[tuple]:
    """Resolve *name* to ``(canonical_key, source)`` or ``None``. Exact key/manifest-name match
    first; then a bare leaf match (``langfuse`` -> ``observability/langfuse``) only when unique,
    so a same-named nested plugin is never picked silently."""
    entries = _discover_all_plugins()
    for entry in entries:
        if name in (entry[0], entry[5]):
            return (entry[5], entry[3])
    leaf_matches = [(entry[5], entry[3]) for entry in entries if name == entry[5].split("/")[-1]]
    return leaf_matches[0] if len(leaf_matches) == 1 else None


def cmd_enable(name: str, allow_tool_override: Optional[bool] = None) -> None:
    """Add a plugin to the enabled allow-list (and remove it from disabled).

    Non-bundled plugins request consent for declared capabilities. The legacy
    ``allow_tool_override`` grant changes only with an explicit True/False flag;
    None leaves it unchanged. Bundled plugins are trusted.
    """
    from hermes_cli.relay_plugin_cutover import LEGACY_RELAY_PLUGIN_KEYS, RELAY_PLUGINS_CONFIG_ENV
    console = _console()

    def _refuse_legacy_relay(plugin: str) -> None:
        if plugin in LEGACY_RELAY_PLUGIN_KEYS:
            _fail(console, (
                f"[red]Plugin '{plugin}' was removed.[/red] Relay lifecycle is owned "
                "by Hermes core; configure a standard user or system Relay plugins.toml, or use "
                f"{RELAY_PLUGINS_CONFIG_ENV} for an explicit user-file override."))

    _refuse_legacy_relay(name)
    resolved = _resolve_plugin_key_and_source(name)
    if resolved is None:
        _fail(console, _unknown_plugin_message(name))
    key, source = resolved
    _refuse_legacy_relay(key)
    if source != "bundled":
        # Activating recalled code is the same act as installing it (`plugins/AGENTS.md`: kill list).
        from hermes_cli import plugins_cmd_catalog as catalog
        try:
            catalog.refuse_if_installed_removed(key, _user_installed_plugin_dir(key.rsplit("/", 1)[-1]))
        except PluginOperationError as exc:
            _fail(console, f"[red]Error:[/red] {exc}")

    if _activate_key(key, enable=True, console=console):
        from hermes_cli.plugins_activation import activate_plugin_now, activation_hint
        console.print(f"[green]✓[/green] Plugin [bold]{key}[/bold] enabled. Takes effect on next session.")
        console.print(f"[dim]{activation_hint(activate_plugin_now(key, in_process=False))}[/dim]")
    else:
        console.print(f"[dim]Plugin '{key}' is already enabled.[/dim]")

    # Built-in tool override is a privileged grant; bundled plugins are trusted.
    if source == "bundled":
        return
    # When the manifest declares capabilities the consent screen is the canonical grant path
    # (it covers tools.override too); the legacy prompt then only runs on an explicit flag.
    # See #64228.
    declared_caps = _declared_capabilities_for_key(key)
    if declared_caps:
        _run_capability_consent(console, key, declared_caps, context="enable")
    # Enabling a plugin is not a request for undeclared privileges. Keep existing
    # grants unchanged unless the operator explicitly grants or revokes one.
    if allow_tool_override is not None:
        _resolve_tool_override_grant(console, key, allow_tool_override)


def cmd_disable(name: str) -> None:
    """Remove a plugin from the enabled allow-list (and add to disabled)."""
    console = _console()
    key = _resolve_plugin_key(name)
    if key is None:
        _fail(console, _unknown_plugin_message(name))
    if not _activate_key(key, enable=False, console=console):
        console.print(f"[dim]Plugin '{key}' is already disabled.[/dim]")
        return
    console.print(
        f"[yellow]\u2298[/yellow] Plugin [bold]{key}[/bold] disabled. Takes effect on next session.")


def _read_manifest_info(d: Path, prefix: str):
    """Read a native or portable manifest and return display metadata."""
    manifest_file = _native_manifest_file(d)
    if manifest_file is None:
        if not _has_portable_manifest(d):
            return None
        try:
            from hermes_cli.agent_plugins import read_agent_plugin_manifest
            manifest = read_agent_plugin_manifest(d)[0]
            name = manifest["name"]
        except Exception:
            return None
    else:
        # Unreadable YAML (or no yaml module) degrades to the directory name, silently.
        try:
            manifest = _load_yaml_manifest(manifest_file)
        except Exception:
            manifest = {}
        if not isinstance(manifest, dict):
            manifest = {}
        name = manifest.get("name", d.name)
    key = f"{prefix}/{d.name}" if prefix else name
    return name, manifest.get("version", ""), manifest.get("description", ""), key


def _is_portable_plugin_dir(dir_path) -> bool:
    """True for an Agent Plugins v1 package (``plugin.json`` only; native ``plugin.yaml`` wins)."""
    try:
        d = Path(dir_path)
        return d.is_dir() and _native_manifest_file(d) is None and _has_portable_manifest(d)
    except OSError:
        return False


# Manifest kinds active-by-default when bundled (backends auto-load, platforms register lazily,
# model providers go through providers/ discovery). Standalone/exclusive kinds stay opt-in.
_BUNDLED_DEFAULT_ON_KINDS = frozenset({"backend", "platform", "model-provider"})


def _bundled_default_on(dir_path) -> bool:
    """True when a bundled plugin is active without a ``plugins.enabled`` entry (portable
    ``plugin.json`` packages have no kind, so never)."""
    manifest_file = _native_manifest_file(Path(dir_path))
    if manifest_file is None:
        return False
    try:
        kind = str(_load_yaml_manifest(manifest_file).get("kind", "standalone")).strip().lower()
        return kind in _BUNDLED_DEFAULT_ON_KINDS
    except Exception:
        return False


def _scan_level(base: Path, source: str, skip_names: set, prefix: str, depth: int, seen: dict) -> None:
    """Recursive directory scan matching PluginManager._scan_directory_level."""
    if not base.is_dir():
        return
    try:
        children = sorted(base.iterdir())
    except OSError as exc:
        logger.warning("Skipping unreadable plugin directory %s: %s", base, exc)
        return
    for d in children:
        try:
            if not d.is_dir() or (depth == 0 and skip_names and d.name in skip_names):
                continue
            info = _read_manifest_info(d, prefix)
        except (OSError, PluginOperationError) as exc:
            # The PM declaration reader wraps unreadable manifests for install callers;
            # listing still skips them rather than hiding every other plugin.
            logger.warning("Skipping unreadable plugin directory %s: %s", d, exc)
            continue
        if info is None:
            if depth == 0:
                _scan_level(d, source, set(), f"{prefix}/{d.name}" if prefix else d.name, 1, seen)
            continue
        name, version, description, key = info
        if key in seen and source == "bundled":
            continue
        src_label = "git" if source == "user" and (d / ".git").exists() else source
        seen[key] = (name, version, description, src_label, d, key)


def _discover_all_plugins() -> list:
    """``(name, version, description, source, dir_path, key)`` for every plugin the loader sees,
    in ``PluginManager.discover_and_load`` order: bundled, user, then entry points — which never
    displace a directory plugin of the same key (see ``PluginManager._discover_and_load_inner``)."""
    seen: dict = {}
    # memory/, context_engine/ and model-providers/ load through dedicated registries, not the
    # PluginManager opt-in surface, so listing them as toggleable plugins would mislead.
    from hermes_cli.plugins import discover_entrypoint_manifests, get_bundled_plugins_dir
    for base, source, skip in (
        (get_bundled_plugins_dir(), "bundled", {"memory", "context_engine", "model-providers"}),
        (_plugins_dir(), "user", set()),
    ):
        _scan_level(base, source, skip, "", 0, seen)
    # Entry-point plugins are installed as Python packages, so they have no plugin directory.
    for m in discover_entrypoint_manifests():
        seen.setdefault(m.name, (m.name, m.version, m.description, "entrypoint", m.path, m.name))
    return list(seen.values())


def _category_active_names() -> set:
    """Provider names switched on through ``<category>.provider`` config rather than
    ``plugins.enabled`` (the live memory provider), so status never calls them "not enabled"."""
    return {n for n in (_get_current_memory_provider(),) if n}


def _plugin_status(name: str, enabled: set, disabled: set, key: str = "", *, source: str = "",
                   dir_path=None, active: "frozenset | set" = frozenset()) -> str:
    """User-facing activation state for a plugin name or key. Mirrors ``gate_manifest``: an explicit
    disable wins, then the allow-list, then the activations that need no list entry — bundled
    backends/platforms/model providers (*source* + *dir_path*) and category-selected providers
    (*active*, see :func:`_category_active_names`)."""
    names = {name, key}
    if names & disabled:
        return "disabled"
    if names & enabled or names & active:
        return "enabled"
    if source == "bundled" and dir_path is not None and _bundled_default_on(dir_path):
        return "enabled"
    return "not enabled"


# ── Provider category config accessors ──────────────────────────────────────────────────────


# memory.provider ("" = built-in) and context.engine config accessors.
_get_current_memory_provider = functools.partial(_config_str, "memory", "provider", default="")
_get_current_context_engine = functools.partial(_config_str, "context", "engine", default="compressor")
_save_memory_provider = functools.partial(_write_config_value, "memory", "provider")
_save_context_engine = functools.partial(_write_config_value, "context", "engine")


def _get_plugin_toolset_key(name: str) -> Optional[str]:
    """Toolset key a plugin registers its tools under, or None: from the live registry (plugin
    already loaded), else from ``provides_tools`` in plugin.yaml looked up in the registry."""
    try:
        from tools.registry import registry
    except Exception:
        return None

    def _first_toolset(tool_names) -> Optional[str]:
        return next((e.toolset for t in tool_names if (e := registry.get_entry(t)) and e.toolset), None)

    def _from_loaded_plugin() -> Optional[str]:
        from hermes_cli.plugins import discover_plugins, get_plugin_manager
        discover_plugins()  # idempotent — ensures plugins are loaded
        for _key, loaded in get_plugin_manager()._plugins.items():
            if loaded.manifest.name == name or _key == name:
                return _first_toolset(loaded.tools_registered)
        return None

    def _from_manifest_on_disk() -> Optional[str]:
        from hermes_cli.plugins import get_bundled_plugins_dir
        return next((
            toolset for base in (get_bundled_plugins_dir(), _plugins_dir())
            if base.is_dir() and (base / name).is_dir()
            and (toolset := _first_toolset(_read_manifest(base / name).get("provides_tools") or []))
        ), None)

    for lookup in (_from_loaded_plugin, _from_manifest_on_disk):
        try:
            if toolset := lookup():
                return toolset
        except Exception:
            continue
    return None


def _toggle_plugin_toolset(name: str, *, enable: bool) -> None:
    """Add/remove a plugin's toolset in ``platform_toolsets`` for all platforms (no-op when the
    plugin provides no tools)."""
    toolset_key = _get_plugin_toolset_key(name)
    if not toolset_key:
        return
    from hermes_cli.config import load_config, save_config
    from hermes_cli.toolset_validation import parse_platform_toolsets_value
    config = load_config()
    platform_toolsets = _child_dict(config, "platform_toolsets")
    changed = False
    for platform, raw in list(platform_toolsets.items()):
        # A list-literal string (older `hermes config set`) is the user's real selection; toggling
        # it re-saves the entry as a proper list so the string never persists.
        ts_list = parse_platform_toolsets_value(raw)
        if ts_list is not None and enable != (toolset_key in ts_list):
            (ts_list.append if enable else ts_list.remove)(toolset_key)
            platform_toolsets[platform] = ts_list
            changed = True
    # Enabling with no platform lists yet: seed "cli" at minimum.
    if enable and not changed and not platform_toolsets:
        platform_toolsets["cli"] = [toolset_key]
        changed = True
    if changed:
        save_config(config)


def dashboard_set_agent_plugin_enabled(name: str, *, enabled: bool) -> dict[str, Any]:
    """Enable or disable a plugin in ``config.yaml`` (runtime allow/deny lists). *name* may be the
    canonical key, the manifest name or a unique bare leaf; the canonical key is what gets written
    (the loader never matches a bare leaf, and a stale key in ``disabled`` outranks a manifest-name
    entry in ``enabled`` — so writing the raw identifier reported success while the plugin stayed off)."""
    key = _resolve_plugin_key(name)
    if key is None:
        return {"ok": False, "error": f"Plugin '{name}' is not installed or bundled."}
    from hermes_cli.plugins_admission import AdmissionRefused

    try:
        changed = _activate_key(key, enable=enabled)
    except AdmissionRefused as exc:
        return {
            "ok": False,
            "error": str(exc),
            "name": key,
            "unchanged": True,
            "restart_required": False,
        }
    if changed:
        _toggle_plugin_toolset(key, enable=enabled)
    if changed and enabled:
        # Load it now, here and in the running gateway; ``activation`` tells the UI what is live vs
        # deferred, and ``restart_required`` only survives when no gateway answered (#87770).
        from hermes_cli.plugins_activation import activate_plugin_now
        return {"ok": True, "name": key, "unchanged": False, **activate_plugin_now(key)}
    # Disable is config-only: there is no un-wire primitive, so a running gateway keeps the plugin's
    # handlers until restart and every UI says so — #71595/#54941.
    return {"ok": True, "name": key, "unchanged": not changed, "restart_required": changed}


def _user_installed_plugin_dir(name: str) -> Optional[Path]:
    """Resolved path under ``~/.hermes/plugins/<name>`` if it exists."""
    try:
        target = _sanitize_plugin_name(name, _plugins_dir(), allow_subdir=True)
    except ValueError:
        return None
    return target if target.is_dir() else None


def cmd_plugin_doctor(target: str = ".", *, ci: bool = False) -> None:
    """Validate one plugin through runtime discovery and registration."""
    from hermes_cli.plugin_dev import doctor_plugin
    report = doctor_plugin(target)
    _console().print(report.format_text())
    if ci and not report.ok:
        raise SystemExit(1)


def _tri_state_flag(args, yes_attr: str, no_attr: str) -> Optional[bool]:
    """Map an argparse ``--x`` / ``--no-x`` pair to True / False / None (neither given)."""
    return True if getattr(args, yes_attr, False) else (False if getattr(args, no_attr, False) else None)


def _catalog():
    from hermes_cli import plugins_cmd_catalog
    return plugins_cmd_catalog


def _action_pack(args):
    from hermes_cli.plugin_packs import pack_command
    pack_command(args)


# Tri-state flags: neither --x nor --no-x given == None == interactive prompt.
_PLUGIN_ACTIONS = {
    "install": lambda args: cmd_install(
        args.identifier,
        force=getattr(args, "force", False),
        enable=_tri_state_flag(args, "enable", "no_enable"),
        ref=getattr(args, "ref", None),
        allow_removed=getattr(args, "allow_removed", False),
        no_deps=getattr(args, "no_deps", False)),
    "search": lambda args: _catalog().cmd_search(
        getattr(args, "term", "") or "", json_output=getattr(args, "json", False)),
    "browse": lambda args: _catalog().cmd_search(""),
    "validate": lambda args: _catalog().cmd_validate(
        args.path, as_json=getattr(args, "json", False), install_deps=getattr(args, "install_deps", False)),
    "update": lambda args: cmd_update(args.name),
    "adopt": lambda args: cmd_adopt(args.name),
    "trust-update-url": lambda args: cmd_trust_update_url(args.name),
    "check-updates": lambda args: cmd_check_updates(args),
    "check": lambda args: cmd_check_updates(args),
    "remove": lambda args: cmd_remove(args.name),
    "rm": lambda args: cmd_remove(args.name),
    "uninstall": lambda args: cmd_remove(args.name),
    "enable": lambda args: cmd_enable(
        args.name,
        allow_tool_override=_tri_state_flag(args, "allow_tool_override", "no_allow_tool_override")),
    "disable": lambda args: cmd_disable(args.name),
    "capabilities": lambda args: cmd_capabilities(getattr(args, "name", None)),
    "list": lambda args: cmd_list(args),
    "ls": lambda args: cmd_list(args),
    "doctor": lambda args: cmd_plugin_doctor(args.target, ci=getattr(args, "ci", False)),
    "pack": _action_pack,
    "show": lambda args: cmd_show(args.name),
    "info": lambda args: _catalog().cmd_info(args.name),
    None: lambda args: cmd_toggle(),
}


def plugins_command(args) -> None:
    """Dispatch hermes plugins subcommands."""
    action = getattr(args, "plugins_action", None)
    handler = _PLUGIN_ACTIONS.get(action)
    if handler is None:
        _fail(_console(), f"[red]Unknown plugins action: {action}[/red]")
    handler(args)
