/ 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)