/ hermes_cli / plugins_cmd.py
plugins_cmd.py
   1  """``hermes plugins`` CLI subcommand — install, update, remove, and list plugins.
   2  
   3  Plugins are installed from Git repositories into ``~/.hermes/plugins/``.
   4  Supports full URLs and ``owner/repo`` shorthand (resolves to GitHub).
   5  
   6  After install, if the plugin ships an ``after-install.md`` file it is
   7  rendered with Rich Markdown.  Otherwise a default confirmation is shown.
   8  """
   9  
  10  from __future__ import annotations
  11  
  12  import logging
  13  import os
  14  import shutil
  15  import subprocess
  16  import sys
  17  from pathlib import Path
  18  from typing import Any, Optional
  19  
  20  from hermes_constants import get_hermes_home
  21  from hermes_cli.config import cfg_get
  22  
  23  logger = logging.getLogger(__name__)
  24  
  25  
  26  class PluginOperationError(Exception):
  27      """Recoverable plugin install/update failure (CLI exits; HTTP maps to 4xx)."""
  28  
  29  
  30  # Minimum manifest version this installer understands.
  31  # Plugins may declare ``manifest_version: 1`` in plugin.yaml;
  32  # future breaking changes to the manifest schema bump this.
  33  _SUPPORTED_MANIFEST_VERSION = 1
  34  
  35  
  36  def _plugins_dir() -> Path:
  37      """Return the user plugins directory, creating it if needed."""
  38      plugins = get_hermes_home() / "plugins"
  39      plugins.mkdir(parents=True, exist_ok=True)
  40      return plugins
  41  
  42  
  43  def _sanitize_plugin_name(name: str, plugins_dir: Path) -> Path:
  44      """Validate a plugin name and return the safe target path inside *plugins_dir*.
  45  
  46      Raises ``ValueError`` if the name contains path-traversal sequences or would
  47      resolve outside the plugins directory.
  48      """
  49      if not name:
  50          raise ValueError("Plugin name must not be empty.")
  51  
  52      if name in (".", ".."):
  53          raise ValueError(
  54              f"Invalid plugin name '{name}': must not reference the plugins directory itself."
  55          )
  56  
  57      # Reject obvious traversal characters
  58      for bad in ("/", "\\", ".."):
  59          if bad in name:
  60              raise ValueError(f"Invalid plugin name '{name}': must not contain '{bad}'.")
  61  
  62      target = (plugins_dir / name).resolve()
  63      plugins_resolved = plugins_dir.resolve()
  64  
  65      if target == plugins_resolved:
  66          raise ValueError(
  67              f"Invalid plugin name '{name}': resolves to the plugins directory itself."
  68          )
  69  
  70      try:
  71          target.relative_to(plugins_resolved)
  72      except ValueError:
  73          raise ValueError(
  74              f"Invalid plugin name '{name}': resolves outside the plugins directory."
  75          )
  76  
  77      return target
  78  
  79  
  80  def _resolve_git_url(identifier: str) -> str:
  81      """Turn an identifier into a cloneable Git URL.
  82  
  83      Accepted formats:
  84      - Full URL: https://github.com/owner/repo.git
  85      - Full URL: git@github.com:owner/repo.git
  86      - Full URL: ssh://git@github.com/owner/repo.git
  87      - Shorthand: owner/repo  →  https://github.com/owner/repo.git
  88  
  89      NOTE: ``http://`` and ``file://`` schemes are accepted but will trigger a
  90      security warning at install time.
  91      """
  92      # Already a URL
  93      if identifier.startswith(("https://", "http://", "git@", "ssh://", "file://")):
  94          return identifier
  95  
  96      # owner/repo shorthand
  97      parts = identifier.strip("/").split("/")
  98      if len(parts) == 2:
  99          owner, repo = parts
 100          return f"https://github.com/{owner}/{repo}.git"
 101  
 102      raise ValueError(
 103          f"Invalid plugin identifier: '{identifier}'. "
 104          "Use a Git URL or owner/repo shorthand."
 105      )
 106  
 107  
 108  def _repo_name_from_url(url: str) -> str:
 109      """Extract the repo name from a Git URL for the plugin directory name."""
 110      # Strip trailing .git and slashes
 111      name = url.rstrip("/")
 112      if name.endswith(".git"):
 113          name = name[:-4]
 114      # Get last path component
 115      name = name.rsplit("/", 1)[-1]
 116      # Handle ssh-style urls: git@github.com:owner/repo
 117      if ":" in name:
 118          name = name.rsplit(":", 1)[-1].rsplit("/", 1)[-1]
 119      return name
 120  
 121  
 122  def _read_manifest(plugin_dir: Path) -> dict:
 123      """Read plugin.yaml and return the parsed dict, or empty dict."""
 124      manifest_file = plugin_dir / "plugin.yaml"
 125      if not manifest_file.exists():
 126          return {}
 127      try:
 128          import yaml
 129  
 130          with open(manifest_file) as f:
 131              return yaml.safe_load(f) or {}
 132      except Exception as e:
 133          logger.warning("Failed to read plugin.yaml in %s: %s", plugin_dir, e)
 134          return {}
 135  
 136  
 137  def _copy_example_files(plugin_dir: Path, console) -> None:
 138      """Copy any .example files to their real names if they don't already exist.
 139  
 140      For example, ``config.yaml.example`` becomes ``config.yaml``.
 141      Skips files that already exist to avoid overwriting user config on reinstall.
 142      """
 143      for example_file in plugin_dir.glob("*.example"):
 144          real_name = example_file.stem  # e.g. "config.yaml" from "config.yaml.example"
 145          real_path = plugin_dir / real_name
 146          if not real_path.exists():
 147              try:
 148                  shutil.copy2(example_file, real_path)
 149                  console.print(
 150                      f"[dim]  Created {real_name} from {example_file.name}[/dim]"
 151                  )
 152              except OSError as e:
 153                  console.print(
 154                      f"[yellow]Warning:[/yellow] Failed to copy {example_file.name}: {e}"
 155                  )
 156  
 157  
 158  def _missing_requires_env_names(manifest: dict) -> list[str]:
 159      """Return declared ``requires_env`` names that are unset in ``~/.hermes/.env``."""
 160      requires_env = manifest.get("requires_env") or []
 161      if not requires_env:
 162          return []
 163  
 164      from hermes_cli.config import get_env_value
 165  
 166      env_specs: list[dict] = []
 167      for entry in requires_env:
 168          if isinstance(entry, str):
 169              env_specs.append({"name": entry})
 170          elif isinstance(entry, dict) and entry.get("name"):
 171              env_specs.append(entry)
 172  
 173      return [s["name"] for s in env_specs if s.get("name") and not get_env_value(s["name"])]
 174  
 175  
 176  def _prompt_plugin_env_vars(manifest: dict, console) -> None:
 177      """Prompt for required environment variables declared in plugin.yaml.
 178  
 179      ``requires_env`` accepts two formats:
 180  
 181      Simple list (backwards-compatible)::
 182  
 183          requires_env:
 184            - MY_API_KEY
 185  
 186      Rich list with metadata::
 187  
 188          requires_env:
 189            - name: MY_API_KEY
 190              description: "API key for Acme service"
 191              url: "https://acme.com/keys"
 192              secret: true
 193  
 194      Already-set variables are skipped.  Values are saved to the user's ``.env``.
 195      """
 196      requires_env = manifest.get("requires_env") or []
 197      if not requires_env:
 198          return
 199  
 200      from hermes_cli.config import get_env_value, save_env_value  # noqa: F811
 201      from hermes_constants import display_hermes_home
 202  
 203      # Normalise to list-of-dicts
 204      env_specs: list[dict] = []
 205      for entry in requires_env:
 206          if isinstance(entry, str):
 207              env_specs.append({"name": entry})
 208          elif isinstance(entry, dict) and entry.get("name"):
 209              env_specs.append(entry)
 210  
 211      # Filter to only vars that aren't already set
 212      missing = [s for s in env_specs if not get_env_value(s["name"])]
 213      if not missing:
 214          return
 215  
 216      plugin_name = manifest.get("name", "this plugin")
 217      console.print(f"\n[bold]{plugin_name}[/bold] requires the following environment variables:\n")
 218  
 219      for spec in missing:
 220          name = spec["name"]
 221          desc = spec.get("description", "")
 222          url = spec.get("url", "")
 223          secret = spec.get("secret", False)
 224  
 225          label = f"  {name}"
 226          if desc:
 227              label += f" — {desc}"
 228          console.print(label)
 229          if url:
 230              console.print(f"  [dim]Get yours at: {url}[/dim]")
 231  
 232          try:
 233              if secret:
 234                  import getpass
 235                  value = getpass.getpass(f"  {name}: ").strip()
 236              else:
 237                  value = input(f"  {name}: ").strip()
 238          except (EOFError, KeyboardInterrupt):
 239              console.print(f"\n[dim]  Skipped (you can set these later in {display_hermes_home()}/.env)[/dim]")
 240              return
 241  
 242          if value:
 243              save_env_value(name, value)
 244              os.environ[name] = value
 245              console.print(f"  [green]✓[/green] Saved to {display_hermes_home()}/.env")
 246          else:
 247              console.print(f"  [dim]  Skipped (set {name} in {display_hermes_home()}/.env later)[/dim]")
 248  
 249      console.print()
 250  
 251  
 252  def _display_after_install(plugin_dir: Path, identifier: str) -> None:
 253      """Show after-install.md if it exists, otherwise a default message."""
 254      from rich.console import Console
 255      from rich.markdown import Markdown
 256      from rich.panel import Panel
 257  
 258      console = Console()
 259      after_install = plugin_dir / "after-install.md"
 260  
 261      if after_install.exists():
 262          content = after_install.read_text(encoding="utf-8")
 263          md = Markdown(content)
 264          console.print()
 265          console.print(Panel(md, border_style="green", expand=False))
 266          console.print()
 267      else:
 268          console.print()
 269          console.print(
 270              Panel(
 271                  f"[green bold]Plugin installed:[/] {identifier}\n"
 272                  f"[dim]Location:[/] {plugin_dir}",
 273                  border_style="green",
 274                  title="✓ Installed",
 275                  expand=False,
 276              )
 277          )
 278          console.print()
 279  
 280  
 281  def _display_removed(name: str, plugins_dir: Path) -> None:
 282      """Show confirmation after removing a plugin."""
 283      from rich.console import Console
 284  
 285      console = Console()
 286      console.print()
 287      console.print(f"[red]✗[/red] Plugin [bold]{name}[/bold] removed from {plugins_dir}")
 288      console.print()
 289  
 290  
 291  def _require_installed_plugin(name: str, plugins_dir: Path, console) -> Path:
 292      """Return the plugin path if it exists, or exit with an error listing installed plugins."""
 293      target = _sanitize_plugin_name(name, plugins_dir)
 294      if not target.exists():
 295          installed = ", ".join(d.name for d in plugins_dir.iterdir() if d.is_dir()) or "(none)"
 296          console.print(
 297              f"[red]Error:[/red] Plugin '{name}' not found in {plugins_dir}.\n"
 298              f"Installed plugins: {installed}"
 299          )
 300          sys.exit(1)
 301      return target
 302  
 303  
 304  # ---------------------------------------------------------------------------
 305  # Commands
 306  # ---------------------------------------------------------------------------
 307  
 308  
 309  def _install_plugin_core(identifier: str, *, force: bool) -> tuple[Path, dict, str]:
 310      """Clone Git plugin into ``~/.hermes/plugins``.
 311  
 312      Returns ``(target_dir, installed_manifest, canonical_name)``.
 313      Raises ``PluginOperationError`` on failure.
 314      """
 315      import tempfile
 316  
 317      try:
 318          git_url = _resolve_git_url(identifier)
 319      except ValueError as e:
 320          raise PluginOperationError(str(e)) from e
 321  
 322      plugins_dir = _plugins_dir()
 323  
 324      with tempfile.TemporaryDirectory() as tmp:
 325          tmp_target = Path(tmp) / "plugin"
 326  
 327          try:
 328              result = subprocess.run(
 329                  ["git", "clone", "--depth", "1", git_url, str(tmp_target)],
 330                  capture_output=True,
 331                  text=True,
 332                  timeout=60,
 333              )
 334          except FileNotFoundError as e:
 335              raise PluginOperationError(
 336                  "git is not installed or not in PATH.",
 337              ) from e
 338          except subprocess.TimeoutExpired as e:
 339              raise PluginOperationError(
 340                  "Git clone timed out after 60 seconds.",
 341              ) from e
 342  
 343          if result.returncode != 0:
 344              err = (result.stderr or result.stdout or "").strip()
 345              raise PluginOperationError(f"Git clone failed:\n{err}")
 346  
 347          manifest = _read_manifest(tmp_target)
 348          plugin_name = manifest.get("name") or _repo_name_from_url(git_url)
 349  
 350          try:
 351              target = _sanitize_plugin_name(plugin_name, plugins_dir)
 352          except ValueError as e:
 353              raise PluginOperationError(str(e)) from e
 354  
 355          mv = manifest.get("manifest_version")
 356          if mv is not None:
 357              try:
 358                  mv_int = int(mv)
 359              except (ValueError, TypeError):
 360                  raise PluginOperationError(
 361                      f"Plugin '{plugin_name}' has invalid manifest_version "
 362                      f"'{mv}' (expected an integer).",
 363                  ) from None
 364              if mv_int > _SUPPORTED_MANIFEST_VERSION:
 365                  from hermes_cli.config import recommended_update_command
 366  
 367                  raise PluginOperationError(
 368                      f"Plugin '{plugin_name}' requires manifest_version {mv}, "
 369                      f"but this installer only supports up to {_SUPPORTED_MANIFEST_VERSION}. "
 370                      f"Run {recommended_update_command()} to update Hermes.",
 371                  ) from None
 372  
 373          if target.exists():
 374              if not force:
 375                  raise PluginOperationError(
 376                      f"Plugin '{plugin_name}' already exists. Use force reinstall "
 377                      f"or run `hermes plugins update {plugin_name}`.",
 378                  )
 379              shutil.rmtree(target)
 380  
 381          shutil.move(str(tmp_target), str(target))
 382  
 383      has_yaml = (target / "plugin.yaml").exists() or (target / "plugin.yml").exists()
 384      if not has_yaml and not (target / "__init__.py").exists():
 385          logger.warning(
 386              "%s has no plugin.yaml / __init__.py; may not be a valid plugin",
 387              plugin_name,
 388          )
 389  
 390      from rich.console import Console
 391  
 392      _copy_example_files(target, Console())
 393      installed_manifest = _read_manifest(target)
 394      installed_name = installed_manifest.get("name") or target.name
 395      return target, installed_manifest, installed_name
 396  
 397  
 398  def cmd_install(
 399      identifier: str,
 400      force: bool = False,
 401      enable: Optional[bool] = None,
 402  ) -> None:
 403      """Install a plugin from a Git URL or owner/repo shorthand.
 404  
 405      After install, prompt "Enable now? [y/N]" unless *enable* is provided
 406      (True = auto-enable without prompting, False = install disabled).
 407      """
 408      from rich.console import Console
 409  
 410      console = Console()
 411  
 412      try:
 413          git_url = _resolve_git_url(identifier)
 414      except ValueError as e:
 415          console.print(f"[red]Error:[/red] {e}")
 416          sys.exit(1)
 417  
 418      if git_url.startswith(("http://", "file://")):
 419          console.print(
 420              "[yellow]Warning:[/yellow] Using insecure/local URL scheme. "
 421              "Consider using https:// or git@ for production installs.",
 422          )
 423  
 424      console.print(f"[dim]Cloning {git_url}...[/dim]")
 425  
 426      try:
 427          target, installed_manifest, installed_name = _install_plugin_core(
 428              identifier,
 429              force=force,
 430          )
 431      except PluginOperationError as e:
 432          console.print(f"[red]Error:[/red] {e}")
 433          sys.exit(1)
 434  
 435      if not (target / "plugin.yaml").exists() and not (target / "plugin.yml").exists() and not (
 436          target / "__init__.py"
 437      ).exists():
 438          console.print(
 439              f"[yellow]Warning:[/yellow] {installed_name} doesn't contain plugin.yaml "
 440              f"or __init__.py. It may not be a valid Hermes plugin.",
 441          )
 442  
 443      _prompt_plugin_env_vars(installed_manifest, console)
 444  
 445      _display_after_install(target, identifier)
 446  
 447      should_enable = enable
 448      if should_enable is None:
 449          if sys.stdin.isatty() and sys.stdout.isatty():
 450              try:
 451                  answer = input(
 452                      f"  Enable '{installed_name}' now? [y/N]: ",
 453                  ).strip().lower()
 454                  should_enable = answer in ("y", "yes")
 455              except (EOFError, KeyboardInterrupt):
 456                  should_enable = False
 457          else:
 458              should_enable = False
 459  
 460      if should_enable:
 461          enabled = _get_enabled_set()
 462          disabled = _get_disabled_set()
 463          enabled.add(installed_name)
 464          disabled.discard(installed_name)
 465          _save_enabled_set(enabled)
 466          _save_disabled_set(disabled)
 467          console.print(
 468              f"[green]✓[/green] Plugin [bold]{installed_name}[/bold] enabled.",
 469          )
 470      else:
 471          console.print(
 472              f"[dim]Plugin installed but not enabled. "
 473              f"Run `hermes plugins enable {installed_name}` to activate.[/dim]",
 474          )
 475  
 476      console.print("[dim]Restart the gateway for the plugin to take effect:[/dim]")
 477      console.print("[dim]  hermes gateway restart[/dim]")
 478      console.print()
 479  
 480  
 481  def cmd_update(name: str) -> None:
 482      """Update an installed plugin by pulling latest from its git remote."""
 483      from rich.console import Console
 484  
 485      console = Console()
 486      plugins_dir = _plugins_dir()
 487  
 488      try:
 489          target = _require_installed_plugin(name, plugins_dir, console)
 490      except ValueError as e:
 491          console.print(f"[red]Error:[/red] {e}")
 492          sys.exit(1)
 493  
 494      if not (target / ".git").exists():
 495          console.print(
 496              f"[red]Error:[/red] Plugin '{name}' was not installed from git "
 497              f"(no .git directory). Cannot update."
 498          )
 499          sys.exit(1)
 500  
 501      console.print(f"[dim]Updating {name}...[/dim]")
 502  
 503      ok, output = _git_pull_plugin_dir(target)
 504      if not ok:
 505          console.print(f"[red]Error:[/red] {output}")
 506          sys.exit(1)
 507  
 508      # Copy any new .example files
 509      _copy_example_files(target, console)
 510  
 511      out = output.strip()
 512      if "Already up to date" in out:
 513          console.print(
 514              f"[green]✓[/green] Plugin [bold]{name}[/bold] is already up to date."
 515          )
 516      else:
 517          console.print(f"[green]✓[/green] Plugin [bold]{name}[/bold] updated.")
 518          console.print(f"[dim]{out}[/dim]")
 519  
 520  
 521  def cmd_remove(name: str) -> None:
 522      """Remove an installed plugin by name."""
 523      from rich.console import Console
 524  
 525      console = Console()
 526      plugins_dir = _plugins_dir()
 527  
 528      try:
 529          target = _require_installed_plugin(name, plugins_dir, console)
 530      except ValueError as e:
 531          console.print(f"[red]Error:[/red] {e}")
 532          sys.exit(1)
 533  
 534      shutil.rmtree(target)
 535      _display_removed(name, plugins_dir)
 536  
 537  
 538  def _get_disabled_set() -> set:
 539      """Read the disabled plugins set from config.yaml.
 540  
 541      An explicit deny-list. A plugin name here never loads, even if also
 542      listed in ``plugins.enabled``.
 543      """
 544      try:
 545          from hermes_cli.config import load_config
 546          config = load_config()
 547          disabled = cfg_get(config, "plugins", "disabled", default=[])
 548          return set(disabled) if isinstance(disabled, list) else set()
 549      except Exception:
 550          return set()
 551  
 552  
 553  def _save_disabled_set(disabled: set) -> None:
 554      """Write the disabled plugins list to config.yaml."""
 555      from hermes_cli.config import load_config, save_config
 556      config = load_config()
 557      if "plugins" not in config:
 558          config["plugins"] = {}
 559      config["plugins"]["disabled"] = sorted(disabled)
 560      save_config(config)
 561  
 562  
 563  def _get_enabled_set() -> set:
 564      """Read the enabled plugins allow-list from config.yaml.
 565  
 566      Plugins are opt-in: only names here are loaded. Returns ``set()`` if
 567      the key is missing (same behaviour as "nothing enabled yet").
 568      """
 569      try:
 570          from hermes_cli.config import load_config
 571          config = load_config()
 572          plugins_cfg = config.get("plugins", {})
 573          if not isinstance(plugins_cfg, dict):
 574              return set()
 575          enabled = plugins_cfg.get("enabled", [])
 576          return set(enabled) if isinstance(enabled, list) else set()
 577      except Exception:
 578          return set()
 579  
 580  
 581  def _save_enabled_set(enabled: set) -> None:
 582      """Write the enabled plugins list to config.yaml."""
 583      from hermes_cli.config import load_config, save_config
 584      config = load_config()
 585      if "plugins" not in config:
 586          config["plugins"] = {}
 587      config["plugins"]["enabled"] = sorted(enabled)
 588      save_config(config)
 589  
 590  
 591  def cmd_enable(name: str) -> None:
 592      """Add a plugin to the enabled allow-list (and remove it from disabled)."""
 593      from rich.console import Console
 594  
 595      console = Console()
 596      # Discover the plugin — check installed (user) AND bundled.
 597      if not _plugin_exists(name):
 598          console.print(f"[red]Plugin '{name}' is not installed or bundled.[/red]")
 599          sys.exit(1)
 600  
 601      enabled = _get_enabled_set()
 602      disabled = _get_disabled_set()
 603  
 604      if name in enabled and name not in disabled:
 605          console.print(f"[dim]Plugin '{name}' is already enabled.[/dim]")
 606          return
 607  
 608      enabled.add(name)
 609      disabled.discard(name)
 610      _save_enabled_set(enabled)
 611      _save_disabled_set(disabled)
 612      console.print(
 613          f"[green]✓[/green] Plugin [bold]{name}[/bold] enabled. "
 614          "Takes effect on next session."
 615      )
 616  
 617  
 618  def cmd_disable(name: str) -> None:
 619      """Remove a plugin from the enabled allow-list (and add to disabled)."""
 620      from rich.console import Console
 621  
 622      console = Console()
 623      if not _plugin_exists(name):
 624          console.print(f"[red]Plugin '{name}' is not installed or bundled.[/red]")
 625          sys.exit(1)
 626  
 627      enabled = _get_enabled_set()
 628      disabled = _get_disabled_set()
 629  
 630      if name not in enabled and name in disabled:
 631          console.print(f"[dim]Plugin '{name}' is already disabled.[/dim]")
 632          return
 633  
 634      enabled.discard(name)
 635      disabled.add(name)
 636      _save_enabled_set(enabled)
 637      _save_disabled_set(disabled)
 638      console.print(
 639          f"[yellow]\u2298[/yellow] Plugin [bold]{name}[/bold] disabled. "
 640          "Takes effect on next session."
 641      )
 642  
 643  
 644  def _plugin_exists(name: str) -> bool:
 645      """Return True if a plugin with *name* is installed (user) or bundled."""
 646      # Installed: directory name or manifest name match in user plugins dir
 647      user_dir = _plugins_dir()
 648      if user_dir.is_dir():
 649          if (user_dir / name).is_dir():
 650              return True
 651          for child in user_dir.iterdir():
 652              if not child.is_dir():
 653                  continue
 654              manifest = _read_manifest(child)
 655              if manifest.get("name") == name:
 656                  return True
 657      # Bundled: <repo>/plugins/<name>/ (or HERMES_BUNDLED_PLUGINS on Nix).
 658      from hermes_cli.plugins import get_bundled_plugins_dir
 659      repo_plugins = get_bundled_plugins_dir()
 660      if repo_plugins.is_dir():
 661          candidate = repo_plugins / name
 662          if candidate.is_dir() and (
 663              (candidate / "plugin.yaml").exists()
 664              or (candidate / "plugin.yml").exists()
 665          ):
 666              return True
 667      return False
 668  
 669  
 670  def _discover_all_plugins() -> list:
 671      """Return a list of (name, version, description, source, dir_path) for
 672      every plugin the loader can see — user + bundled + project.
 673  
 674      Matches the ordering/dedup of ``PluginManager.discover_and_load``:
 675      bundled first, then user, then project; user overrides bundled on
 676      name collision.
 677      """
 678      try:
 679          import yaml
 680      except ImportError:
 681          yaml = None
 682  
 683      seen: dict = {}  # name -> (name, version, description, source, path)
 684  
 685      # Bundled (<repo>/plugins/<name>/), excluding memory/ and context_engine/
 686      from hermes_cli.plugins import get_bundled_plugins_dir
 687      repo_plugins = get_bundled_plugins_dir()
 688      for base, source in ((repo_plugins, "bundled"), (_plugins_dir(), "user")):
 689          if not base.is_dir():
 690              continue
 691          for d in sorted(base.iterdir()):
 692              if not d.is_dir():
 693                  continue
 694              if source == "bundled" and d.name in ("memory", "context_engine"):
 695                  continue
 696              manifest_file = d / "plugin.yaml"
 697              if not manifest_file.exists():
 698                  manifest_file = d / "plugin.yml"
 699              if not manifest_file.exists():
 700                  continue
 701              name = d.name
 702              version = ""
 703              description = ""
 704              if yaml:
 705                  try:
 706                      with open(manifest_file) as f:
 707                          manifest = yaml.safe_load(f) or {}
 708                      name = manifest.get("name", d.name)
 709                      version = manifest.get("version", "")
 710                      description = manifest.get("description", "")
 711                  except Exception:
 712                      pass
 713              # User plugins override bundled on name collision.
 714              if name in seen and source == "bundled":
 715                  continue
 716              src_label = source
 717              if source == "user" and (d / ".git").exists():
 718                  src_label = "git"
 719              seen[name] = (name, version, description, src_label, d)
 720      return list(seen.values())
 721  
 722  
 723  def cmd_list() -> None:
 724      """List all plugins (bundled + user) with enabled/disabled state."""
 725      from rich.console import Console
 726      from rich.table import Table
 727  
 728      console = Console()
 729      entries = _discover_all_plugins()
 730      if not entries:
 731          console.print("[dim]No plugins installed.[/dim]")
 732          console.print("[dim]Install with:[/dim] hermes plugins install owner/repo")
 733          return
 734  
 735      enabled = _get_enabled_set()
 736      disabled = _get_disabled_set()
 737  
 738      table = Table(title="Plugins", show_lines=False)
 739      table.add_column("Name", style="bold")
 740      table.add_column("Status")
 741      table.add_column("Version", style="dim")
 742      table.add_column("Description")
 743      table.add_column("Source", style="dim")
 744  
 745      for name, version, description, source, _dir in entries:
 746          if name in disabled:
 747              status = "[red]disabled[/red]"
 748          elif name in enabled:
 749              status = "[green]enabled[/green]"
 750          else:
 751              status = "[yellow]not enabled[/yellow]"
 752          table.add_row(name, status, str(version), description, source)
 753  
 754      console.print()
 755      console.print(table)
 756      console.print()
 757      console.print("[dim]Interactive toggle:[/dim] hermes plugins")
 758      console.print("[dim]Enable/disable:[/dim] hermes plugins enable/disable <name>")
 759      console.print("[dim]Plugins are opt-in by default — only 'enabled' plugins load.[/dim]")
 760  
 761  
 762  # ---------------------------------------------------------------------------
 763  # Provider plugin discovery helpers
 764  # ---------------------------------------------------------------------------
 765  
 766  
 767  def _discover_memory_providers() -> list[tuple[str, str]]:
 768      """Return [(name, description), ...] for available memory providers."""
 769      try:
 770          from plugins.memory import discover_memory_providers
 771          return [(name, desc) for name, desc, _avail in discover_memory_providers()]
 772      except Exception:
 773          return []
 774  
 775  
 776  def _discover_context_engines() -> list[tuple[str, str]]:
 777      """Return [(name, description), ...] for available context engines."""
 778      try:
 779          from plugins.context_engine import discover_context_engines
 780          return [(name, desc) for name, desc, _avail in discover_context_engines()]
 781      except Exception:
 782          return []
 783  
 784  
 785  def _get_current_memory_provider() -> str:
 786      """Return the current memory.provider from config (empty = built-in)."""
 787      try:
 788          from hermes_cli.config import load_config
 789          config = load_config()
 790          return cfg_get(config, "memory", "provider", default="") or ""
 791      except Exception:
 792          return ""
 793  
 794  
 795  def _get_current_context_engine() -> str:
 796      """Return the current context.engine from config."""
 797      try:
 798          from hermes_cli.config import load_config
 799          config = load_config()
 800          return cfg_get(config, "context", "engine", default="compressor") or "compressor"
 801      except Exception:
 802          return "compressor"
 803  
 804  
 805  def _save_memory_provider(name: str) -> None:
 806      """Persist memory.provider to config.yaml."""
 807      from hermes_cli.config import load_config, save_config
 808      config = load_config()
 809      if "memory" not in config:
 810          config["memory"] = {}
 811      config["memory"]["provider"] = name
 812      save_config(config)
 813  
 814  
 815  def _save_context_engine(name: str) -> None:
 816      """Persist context.engine to config.yaml."""
 817      from hermes_cli.config import load_config, save_config
 818      config = load_config()
 819      if "context" not in config:
 820          config["context"] = {}
 821      config["context"]["engine"] = name
 822      save_config(config)
 823  
 824  
 825  def _configure_memory_provider() -> bool:
 826      """Launch a radio picker for memory providers. Returns True if changed."""
 827      from hermes_cli.curses_ui import curses_radiolist
 828  
 829      current = _get_current_memory_provider()
 830      providers = _discover_memory_providers()
 831  
 832      # Build items: "built-in" first, then discovered providers
 833      items = ["built-in (default)"]
 834      names = [""]  # empty string = built-in
 835      selected = 0
 836  
 837      for name, desc in providers:
 838          names.append(name)
 839          label = f"{name} \u2014 {desc}" if desc else name
 840          items.append(label)
 841          if name == current:
 842              selected = len(items) - 1
 843  
 844      # If current provider isn't in discovered list, add it
 845      if current and current not in names:
 846          names.append(current)
 847          items.append(f"{current} (not found)")
 848          selected = len(items) - 1
 849  
 850      choice = curses_radiolist(
 851          title="Memory Provider (select one)",
 852          items=items,
 853          selected=selected,
 854      )
 855  
 856      new_provider = names[choice]
 857      if new_provider != current:
 858          _save_memory_provider(new_provider)
 859          return True
 860      return False
 861  
 862  
 863  def _configure_context_engine() -> bool:
 864      """Launch a radio picker for context engines. Returns True if changed."""
 865      from hermes_cli.curses_ui import curses_radiolist
 866  
 867      current = _get_current_context_engine()
 868      engines = _discover_context_engines()
 869  
 870      # Build items: "compressor" first (built-in), then discovered engines
 871      items = ["compressor (default)"]
 872      names = ["compressor"]
 873      selected = 0
 874  
 875      for name, desc in engines:
 876          names.append(name)
 877          label = f"{name} \u2014 {desc}" if desc else name
 878          items.append(label)
 879          if name == current:
 880              selected = len(items) - 1
 881  
 882      # If current engine isn't in discovered list and isn't compressor, add it
 883      if current != "compressor" and current not in names:
 884          names.append(current)
 885          items.append(f"{current} (not found)")
 886          selected = len(items) - 1
 887  
 888      choice = curses_radiolist(
 889          title="Context Engine (select one)",
 890          items=items,
 891          selected=selected,
 892      )
 893  
 894      new_engine = names[choice]
 895      if new_engine != current:
 896          _save_context_engine(new_engine)
 897          return True
 898      return False
 899  
 900  
 901  # ---------------------------------------------------------------------------
 902  # Composite plugins UI
 903  # ---------------------------------------------------------------------------
 904  
 905  
 906  def cmd_toggle() -> None:
 907      """Interactive composite UI — general plugins + provider plugin categories."""
 908      from rich.console import Console
 909  
 910      console = Console()
 911  
 912      # -- General plugins discovery (bundled + user) --
 913      entries = _discover_all_plugins()
 914      enabled_set = _get_enabled_set()
 915      disabled_set = _get_disabled_set()
 916  
 917      plugin_names = []
 918      plugin_labels = []
 919      plugin_selected = set()
 920  
 921      for i, (name, _version, description, source, _d) in enumerate(entries):
 922          label = f"{name} \u2014 {description}" if description else name
 923          if source == "bundled":
 924              label = f"{label} [bundled]"
 925          plugin_names.append(name)
 926          plugin_labels.append(label)
 927          # Selected (enabled) when in enabled-set AND not in disabled-set
 928          if name in enabled_set and name not in disabled_set:
 929              plugin_selected.add(i)
 930  
 931      # -- Provider categories --
 932      current_memory = _get_current_memory_provider() or "built-in"
 933      current_context = _get_current_context_engine()
 934      categories = [
 935          ("Memory Provider", current_memory, _configure_memory_provider),
 936          ("Context Engine", current_context, _configure_context_engine),
 937      ]
 938  
 939      has_plugins = bool(plugin_names)
 940      has_categories = bool(categories)
 941  
 942      if not has_plugins and not has_categories:
 943          console.print("[dim]No plugins installed and no provider categories available.[/dim]")
 944          console.print("[dim]Install with:[/dim] hermes plugins install owner/repo")
 945          return
 946  
 947      # Non-TTY fallback
 948      if not sys.stdin.isatty():
 949          console.print("[dim]Interactive mode requires a terminal.[/dim]")
 950          return
 951  
 952      # Launch the composite curses UI
 953      try:
 954          import curses
 955          _run_composite_ui(curses, plugin_names, plugin_labels, plugin_selected,
 956                            disabled_set, categories, console)
 957      except ImportError:
 958          _run_composite_fallback(plugin_names, plugin_labels, plugin_selected,
 959                                  disabled_set, categories, console)
 960  
 961  
 962  def _run_composite_ui(curses, plugin_names, plugin_labels, plugin_selected,
 963                        disabled, categories, console):
 964      """Custom curses screen with checkboxes + category action rows."""
 965      from hermes_cli.curses_ui import flush_stdin
 966  
 967      chosen = set(plugin_selected)
 968      n_plugins = len(plugin_names)
 969      # Total rows: plugins + separator + categories
 970      # separator is not navigable
 971      n_categories = len(categories)
 972      total_items = n_plugins + n_categories  # navigable items
 973  
 974      result_holder = {"plugins_changed": False, "providers_changed": False}
 975  
 976      def _draw(stdscr):
 977          curses.curs_set(0)
 978          if curses.has_colors():
 979              curses.start_color()
 980              curses.use_default_colors()
 981              curses.init_pair(1, curses.COLOR_GREEN, -1)
 982              curses.init_pair(2, curses.COLOR_YELLOW, -1)
 983              curses.init_pair(3, curses.COLOR_CYAN, -1)
 984              curses.init_pair(4, 8, -1)  # dim gray
 985          cursor = 0
 986          scroll_offset = 0
 987  
 988          while True:
 989              stdscr.clear()
 990              max_y, max_x = stdscr.getmaxyx()
 991  
 992              # Header
 993              try:
 994                  hattr = curses.A_BOLD
 995                  if curses.has_colors():
 996                      hattr |= curses.color_pair(2)
 997                  stdscr.addnstr(0, 0, "Plugins", max_x - 1, hattr)
 998                  stdscr.addnstr(
 999                      1, 0,
1000                      "  \u2191\u2193 navigate  SPACE toggle  ENTER configure/confirm  ESC done",
1001                      max_x - 1, curses.A_DIM,
1002                  )
1003              except curses.error:
1004                  pass
1005  
1006              # Build display rows
1007              # Row layout:
1008              #   [plugins section header] (not navigable, skipped in scroll math)
1009              #   plugin checkboxes (navigable, indices 0..n_plugins-1)
1010              #   [separator] (not navigable)
1011              #   [categories section header] (not navigable)
1012              #   category action rows (navigable, indices n_plugins..total_items-1)
1013  
1014              visible_rows = max_y - 4
1015              if cursor < scroll_offset:
1016                  scroll_offset = cursor
1017              elif cursor >= scroll_offset + visible_rows:
1018                  scroll_offset = cursor - visible_rows + 1
1019  
1020              y = 3  # start drawing after header
1021  
1022              # Determine which items are visible based on scroll
1023              # We need to map logical cursor positions to screen rows
1024              # accounting for non-navigable separator/headers
1025  
1026  
1027              # --- General Plugins section ---
1028              if n_plugins > 0:
1029                  # Section header
1030                  if y < max_y - 1:
1031                      try:
1032                          sattr = curses.A_BOLD
1033                          if curses.has_colors():
1034                              sattr |= curses.color_pair(2)
1035                          stdscr.addnstr(y, 0, "  General Plugins", max_x - 1, sattr)
1036                      except curses.error:
1037                          pass
1038                      y += 1
1039  
1040                  for i in range(n_plugins):
1041                      if y >= max_y - 1:
1042                          break
1043                      check = "\u2713" if i in chosen else " "
1044                      arrow = "\u2192" if i == cursor else " "
1045                      line = f" {arrow} [{check}] {plugin_labels[i]}"
1046                      attr = curses.A_NORMAL
1047                      if i == cursor:
1048                          attr = curses.A_BOLD
1049                          if curses.has_colors():
1050                              attr |= curses.color_pair(1)
1051                      try:
1052                          stdscr.addnstr(y, 0, line, max_x - 1, attr)
1053                      except curses.error:
1054                          pass
1055                      y += 1
1056  
1057              # --- Separator ---
1058              if y < max_y - 1:
1059                  y += 1  # blank line
1060  
1061              # --- Provider Plugins section ---
1062              if n_categories > 0 and y < max_y - 1:
1063                  try:
1064                      sattr = curses.A_BOLD
1065                      if curses.has_colors():
1066                          sattr |= curses.color_pair(2)
1067                      stdscr.addnstr(y, 0, "  Provider Plugins", max_x - 1, sattr)
1068                  except curses.error:
1069                      pass
1070                  y += 1
1071  
1072                  for ci, (cat_name, cat_current, _cat_fn) in enumerate(categories):
1073                      if y >= max_y - 1:
1074                          break
1075                      cat_idx = n_plugins + ci
1076                      arrow = "\u2192" if cat_idx == cursor else " "
1077                      line = f" {arrow}   {cat_name:<24} \u25b8 {cat_current}"
1078                      attr = curses.A_NORMAL
1079                      if cat_idx == cursor:
1080                          attr = curses.A_BOLD
1081                          if curses.has_colors():
1082                              attr |= curses.color_pair(3)
1083                      try:
1084                          stdscr.addnstr(y, 0, line, max_x - 1, attr)
1085                      except curses.error:
1086                          pass
1087                      y += 1
1088  
1089              stdscr.refresh()
1090              key = stdscr.getch()
1091  
1092              if key in (curses.KEY_UP, ord("k")):
1093                  if total_items > 0:
1094                      cursor = (cursor - 1) % total_items
1095              elif key in (curses.KEY_DOWN, ord("j")):
1096                  if total_items > 0:
1097                      cursor = (cursor + 1) % total_items
1098              elif key == ord(" "):
1099                  if cursor < n_plugins:
1100                      # Toggle general plugin
1101                      chosen.symmetric_difference_update({cursor})
1102                  else:
1103                      # Provider category — launch sub-screen
1104                      ci = cursor - n_plugins
1105                      if 0 <= ci < n_categories:
1106                          curses.endwin()
1107                          _cat_name, _cat_cur, cat_fn = categories[ci]
1108                          changed = cat_fn()
1109                          if changed:
1110                              result_holder["providers_changed"] = True
1111                              # Refresh current values
1112                              categories[ci] = (
1113                                  _cat_name,
1114                                  _get_current_memory_provider() or "built-in" if ci == 0
1115                                  else _get_current_context_engine(),
1116                                  cat_fn,
1117                              )
1118                          # Re-enter curses
1119                          stdscr = curses.initscr()
1120                          curses.noecho()
1121                          curses.cbreak()
1122                          stdscr.keypad(True)
1123                          if curses.has_colors():
1124                              curses.start_color()
1125                              curses.use_default_colors()
1126                              curses.init_pair(1, curses.COLOR_GREEN, -1)
1127                              curses.init_pair(2, curses.COLOR_YELLOW, -1)
1128                              curses.init_pair(3, curses.COLOR_CYAN, -1)
1129                              curses.init_pair(4, 8, -1)
1130                          curses.curs_set(0)
1131              elif key in (curses.KEY_ENTER, 10, 13):
1132                  if cursor < n_plugins:
1133                      # ENTER on a plugin checkbox — confirm and exit
1134                      result_holder["plugins_changed"] = True
1135                      return
1136                  else:
1137                      # ENTER on a category — same as SPACE, launch sub-screen
1138                      ci = cursor - n_plugins
1139                      if 0 <= ci < n_categories:
1140                          curses.endwin()
1141                          _cat_name, _cat_cur, cat_fn = categories[ci]
1142                          changed = cat_fn()
1143                          if changed:
1144                              result_holder["providers_changed"] = True
1145                              categories[ci] = (
1146                                  _cat_name,
1147                                  _get_current_memory_provider() or "built-in" if ci == 0
1148                                  else _get_current_context_engine(),
1149                                  cat_fn,
1150                              )
1151                          stdscr = curses.initscr()
1152                          curses.noecho()
1153                          curses.cbreak()
1154                          stdscr.keypad(True)
1155                          if curses.has_colors():
1156                              curses.start_color()
1157                              curses.use_default_colors()
1158                              curses.init_pair(1, curses.COLOR_GREEN, -1)
1159                              curses.init_pair(2, curses.COLOR_YELLOW, -1)
1160                              curses.init_pair(3, curses.COLOR_CYAN, -1)
1161                              curses.init_pair(4, 8, -1)
1162                          curses.curs_set(0)
1163              elif key in (27, ord("q")):
1164                  # Save plugin changes on exit
1165                  result_holder["plugins_changed"] = True
1166                  return
1167  
1168      curses.wrapper(_draw)
1169      flush_stdin()
1170  
1171      # Persist general plugin changes. The new allow-list is the set of
1172      # plugin names that were checked; anything not checked is explicitly
1173      # disabled (written to disabled-list) so it remains off even if the
1174      # plugin code does something clever like auto-enable in the future.
1175      new_enabled: set = set()
1176      new_disabled: set = set(disabled)  # preserve existing disabled state for unseen plugins
1177      for i, name in enumerate(plugin_names):
1178          if i in chosen:
1179              new_enabled.add(name)
1180              new_disabled.discard(name)
1181          else:
1182              new_disabled.add(name)
1183  
1184      prev_enabled = _get_enabled_set()
1185      enabled_changed = new_enabled != prev_enabled
1186      disabled_changed = new_disabled != disabled
1187  
1188      if enabled_changed or disabled_changed:
1189          _save_enabled_set(new_enabled)
1190          _save_disabled_set(new_disabled)
1191          console.print(
1192              f"\n[green]\u2713[/green] General plugins: {len(new_enabled)} enabled, "
1193              f"{len(plugin_names) - len(new_enabled)} disabled."
1194          )
1195      elif n_plugins > 0:
1196          console.print("\n[dim]General plugins unchanged.[/dim]")
1197  
1198      if result_holder["providers_changed"]:
1199          new_memory = _get_current_memory_provider() or "built-in"
1200          new_context = _get_current_context_engine()
1201          console.print(
1202              f"[green]\u2713[/green] Memory provider: [bold]{new_memory}[/bold]  "
1203              f"Context engine: [bold]{new_context}[/bold]"
1204          )
1205  
1206      if n_plugins > 0 or result_holder["providers_changed"]:
1207          console.print("[dim]Changes take effect on next session.[/dim]")
1208      console.print()
1209  
1210  
1211  def _run_composite_fallback(plugin_names, plugin_labels, plugin_selected,
1212                              disabled, categories, console):
1213      """Text-based fallback for the composite plugins UI."""
1214      from hermes_cli.colors import Colors, color
1215  
1216      print(color("\n  Plugins", Colors.YELLOW))
1217  
1218      # General plugins
1219      if plugin_names:
1220          chosen = set(plugin_selected)
1221          print(color("\n  General Plugins", Colors.YELLOW))
1222          print(color("  Toggle by number, Enter to confirm.\n", Colors.DIM))
1223  
1224          while True:
1225              for i, label in enumerate(plugin_labels):
1226                  marker = color("[\u2713]", Colors.GREEN) if i in chosen else "[ ]"
1227                  print(f"  {marker} {i + 1:>2}. {label}")
1228              print()
1229              try:
1230                  val = input(color("  Toggle # (or Enter to confirm): ", Colors.DIM)).strip()
1231                  if not val:
1232                      break
1233                  idx = int(val) - 1
1234                  if 0 <= idx < len(plugin_names):
1235                      chosen.symmetric_difference_update({idx})
1236              except (ValueError, KeyboardInterrupt, EOFError):
1237                  return
1238              print()
1239  
1240          new_enabled: set = set()
1241          new_disabled: set = set(disabled)
1242          for i, name in enumerate(plugin_names):
1243              if i in chosen:
1244                  new_enabled.add(name)
1245                  new_disabled.discard(name)
1246              else:
1247                  new_disabled.add(name)
1248          prev_enabled = _get_enabled_set()
1249          if new_enabled != prev_enabled or new_disabled != disabled:
1250              _save_enabled_set(new_enabled)
1251              _save_disabled_set(new_disabled)
1252  
1253      # Provider categories
1254      if categories:
1255          print(color("\n  Provider Plugins", Colors.YELLOW))
1256          for ci, (cat_name, cat_current, cat_fn) in enumerate(categories):
1257              print(f"  {ci + 1}. {cat_name} [{cat_current}]")
1258          print()
1259          try:
1260              val = input(color("  Configure # (or Enter to skip): ", Colors.DIM)).strip()
1261              if val:
1262                  ci = int(val) - 1
1263                  if 0 <= ci < len(categories):
1264                      categories[ci][2]()  # call the configure function
1265          except (ValueError, KeyboardInterrupt, EOFError):
1266              pass
1267  
1268      print()
1269  
1270  
1271  def dashboard_install_plugin(
1272      identifier: str,
1273      *,
1274      force: bool,
1275      enable: bool,
1276  ) -> dict[str, Any]:
1277      """Non-interactive install for the web dashboard. Returns a JSON-serializable dict."""
1278      warnings: list[str] = []
1279      try:
1280          git_url = _resolve_git_url(identifier)
1281          if git_url.startswith(("http://", "file://")):
1282              warnings.append(
1283                  "Insecure URL scheme; prefer https:// or git@ for production installs.",
1284              )
1285      except ValueError:
1286          pass
1287  
1288      try:
1289          target, installed_manifest, installed_name = _install_plugin_core(
1290              identifier,
1291              force=force,
1292          )
1293      except PluginOperationError as exc:
1294          return {"ok": False, "error": str(exc)}
1295  
1296      missing_env = _missing_requires_env_names(installed_manifest)
1297      if enable:
1298          en = _get_enabled_set()
1299          dis = _get_disabled_set()
1300          en.add(installed_name)
1301          dis.discard(installed_name)
1302          _save_enabled_set(en)
1303          _save_disabled_set(dis)
1304  
1305      hint: str | None = None
1306      ap = target / "after-install.md"
1307      if ap.exists():
1308          hint = str(ap)
1309  
1310      return {
1311          "ok": True,
1312          "plugin_name": installed_name,
1313          "warnings": warnings,
1314          "missing_env": missing_env,
1315          "after_install_path": hint,
1316          "enabled": enable,
1317      }
1318  
1319  
1320  def _get_plugin_toolset_key(name: str) -> Optional[str]:
1321      """Return the toolset key a plugin registers its tools under, or None.
1322  
1323      Queries the live tool registry — the plugin must already be loaded.
1324      Falls back to reading ``provides_tools`` from plugin.yaml and looking
1325      up the toolset from the registry for the first tool name found.
1326      """
1327      try:
1328          from tools.registry import registry
1329      except Exception:
1330          return None
1331  
1332      # Check the plugin manager for tools this plugin registered
1333      try:
1334          from hermes_cli.plugins import discover_plugins, get_plugin_manager
1335          discover_plugins()  # idempotent — ensures plugins are loaded
1336          manager = get_plugin_manager()
1337          for _key, loaded in manager._plugins.items():
1338              if loaded.manifest.name == name or _key == name:
1339                  for tool_name in loaded.tools_registered:
1340                      entry = registry.get_entry(tool_name)
1341                      if entry and entry.toolset:
1342                          return entry.toolset
1343                  break
1344      except Exception:
1345          pass
1346  
1347      # Fallback: read provides_tools from manifest on disk and query registry
1348      try:
1349          from hermes_cli.plugins import get_bundled_plugins_dir
1350          for base in (get_bundled_plugins_dir(), _plugins_dir()):
1351              if not base.is_dir():
1352                  continue
1353              candidate = base / name
1354              if candidate.is_dir():
1355                  manifest = _read_manifest(candidate)
1356                  for tool_name in manifest.get("provides_tools") or []:
1357                      entry = registry.get_entry(tool_name)
1358                      if entry and entry.toolset:
1359                          return entry.toolset
1360      except Exception:
1361          pass
1362  
1363      return None
1364  
1365  
1366  def _toggle_plugin_toolset(name: str, *, enable: bool) -> None:
1367      """Add or remove a plugin's toolset from platform_toolsets for all platforms.
1368  
1369      Only acts if the plugin actually provides tools (has a toolset key).
1370      """
1371      toolset_key = _get_plugin_toolset_key(name)
1372      if not toolset_key:
1373          return
1374  
1375      from hermes_cli.config import load_config, save_config
1376  
1377      config = load_config()
1378      platform_toolsets = config.get("platform_toolsets")
1379      if not isinstance(platform_toolsets, dict):
1380          platform_toolsets = {}
1381          config["platform_toolsets"] = platform_toolsets
1382  
1383      changed = False
1384      for platform, ts_list in platform_toolsets.items():
1385          if not isinstance(ts_list, list):
1386              continue
1387          if enable:
1388              if toolset_key not in ts_list:
1389                  ts_list.append(toolset_key)
1390                  changed = True
1391          else:
1392              if toolset_key in ts_list:
1393                  ts_list.remove(toolset_key)
1394                  changed = True
1395  
1396      # If enabling and no platforms have toolset lists yet, add to "cli" at minimum
1397      if enable and not changed and not platform_toolsets:
1398          platform_toolsets["cli"] = [toolset_key]
1399          changed = True
1400  
1401      if changed:
1402          save_config(config)
1403  
1404  
1405  def dashboard_set_agent_plugin_enabled(name: str, *, enabled: bool) -> dict[str, Any]:
1406      """Enable or disable a plugin in ``config.yaml`` (runtime allow/deny lists).
1407  
1408      For plugins that provide tools (toolsets), also toggles the toolset in
1409      ``platform_toolsets`` so the agent actually sees the tools in sessions.
1410      """
1411      if not _plugin_exists(name):
1412          return {"ok": False, "error": f"Plugin '{name}' is not installed or bundled."}
1413  
1414      en = _get_enabled_set()
1415      dis = _get_disabled_set()
1416  
1417      if enabled:
1418          if name in en and name not in dis:
1419              return {"ok": True, "name": name, "unchanged": True}
1420          en.add(name)
1421          dis.discard(name)
1422          _save_enabled_set(en)
1423          _save_disabled_set(dis)
1424          _toggle_plugin_toolset(name, enable=True)
1425          return {"ok": True, "name": name, "unchanged": False}
1426  
1427      if name not in en and name in dis:
1428          return {"ok": True, "name": name, "unchanged": True}
1429  
1430      en.discard(name)
1431      dis.add(name)
1432      _save_enabled_set(en)
1433      _save_disabled_set(dis)
1434      _toggle_plugin_toolset(name, enable=False)
1435      return {"ok": True, "name": name, "unchanged": False}
1436  
1437  
1438  def _user_installed_plugin_dir(name: str) -> Optional[Path]:
1439      """Resolved path under ``~/.hermes/plugins/<name>`` if it exists."""
1440      plugins_dir = _plugins_dir()
1441      try:
1442          target = _sanitize_plugin_name(name, plugins_dir)
1443      except ValueError:
1444          return None
1445      return target if target.is_dir() else None
1446  
1447  
1448  def dashboard_update_user_plugin(name: str) -> dict[str, Any]:
1449      """``git pull`` inside ``~/.hermes/plugins/<name>``."""
1450      target = _user_installed_plugin_dir(name)
1451      if target is None:
1452          return {
1453              "ok": False,
1454              "error": f"Plugin '{name}' was not found under {_plugins_dir()}.",
1455          }
1456  
1457      if not (target / ".git").exists():
1458          return {
1459              "ok": False,
1460              "error": f"Plugin '{name}' is not a git checkout; cannot pull updates.",
1461          }
1462  
1463      ok, msg = _git_pull_plugin_dir(target)
1464      if not ok:
1465          return {"ok": False, "error": msg}
1466  
1467      from rich.console import Console
1468  
1469      _copy_example_files(target, Console())
1470      unchanged = "Already up to date" in msg
1471      return {"ok": True, "name": name, "output": msg, "unchanged": unchanged}
1472  
1473  
1474  def _git_pull_plugin_dir(target: Path) -> tuple[bool, str]:
1475      try:
1476          result = subprocess.run(
1477              ["git", "pull", "--ff-only"],
1478              capture_output=True,
1479              text=True,
1480              timeout=60,
1481              cwd=str(target),
1482          )
1483      except FileNotFoundError:
1484          return False, "git is not installed or not in PATH."
1485      except subprocess.TimeoutExpired:
1486          return False, "Git pull timed out after 60 seconds."
1487  
1488      if result.returncode != 0:
1489          err = (result.stderr or "").strip() or result.stdout.strip()
1490          return False, err or "git pull failed."
1491      return True, result.stdout.strip()
1492  
1493  
1494  def dashboard_remove_user_plugin(name: str) -> dict[str, Any]:
1495      """Delete a plugin tree under ``~/.hermes/plugins/`` only."""
1496      plugins_dir = _plugins_dir()
1497      for n, _ver, _d, src, _path in _discover_all_plugins():
1498          if n == name and src == "bundled":
1499              return {"ok": False, "error": "Bundled plugins cannot be removed from the dashboard."}
1500  
1501      target = _user_installed_plugin_dir(name)
1502      if target is None:
1503          return {
1504              "ok": False,
1505              "error": f"Plugin '{name}' was not found under {plugins_dir}.",
1506          }
1507  
1508      shutil.rmtree(target)
1509      return {"ok": True, "name": name}
1510  
1511  
1512  def plugins_command(args) -> None:
1513      """Dispatch hermes plugins subcommands."""
1514      action = getattr(args, "plugins_action", None)
1515  
1516      if action == "install":
1517          # Map argparse tri-state: --enable=True, --no-enable=False, neither=None (prompt)
1518          enable_arg = None
1519          if getattr(args, "enable", False):
1520              enable_arg = True
1521          elif getattr(args, "no_enable", False):
1522              enable_arg = False
1523          cmd_install(
1524              args.identifier,
1525              force=getattr(args, "force", False),
1526              enable=enable_arg,
1527          )
1528      elif action == "update":
1529          cmd_update(args.name)
1530      elif action in ("remove", "rm", "uninstall"):
1531          cmd_remove(args.name)
1532      elif action == "enable":
1533          cmd_enable(args.name)
1534      elif action == "disable":
1535          cmd_disable(args.name)
1536      elif action in ("list", "ls"):
1537          cmd_list()
1538      elif action is None:
1539          cmd_toggle()
1540      else:
1541          from rich.console import Console
1542  
1543          Console().print(f"[red]Unknown plugins action: {action}[/red]")
1544          sys.exit(1)