| """``hermes plugins`` CLI subcommand — install, update, remove, and list plugins. |
| |
| Plugins are installed from Git repositories into ``~/.hermes/plugins/``. |
| Supports full URLs and ``owner/repo`` shorthand (resolves to GitHub). |
| |
| After install, if the plugin ships an ``after-install.md`` file it is |
| rendered with Rich Markdown. Otherwise a default confirmation is shown. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import logging |
| import os |
| import shutil |
| import subprocess |
| import sys |
| from pathlib import Path |
|
|
| logger = logging.getLogger(__name__) |
|
|
| |
| |
| |
| _SUPPORTED_MANIFEST_VERSION = 1 |
|
|
|
|
| def _plugins_dir() -> Path: |
| """Return the user plugins directory, creating it if needed.""" |
| hermes_home = os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")) |
| plugins = Path(hermes_home) / "plugins" |
| plugins.mkdir(parents=True, exist_ok=True) |
| return plugins |
|
|
|
|
| def _sanitize_plugin_name(name: str, plugins_dir: Path) -> Path: |
| """Validate a plugin name and return the safe target path inside *plugins_dir*. |
| |
| Raises ``ValueError`` if the name contains path-traversal sequences or would |
| resolve outside the plugins directory. |
| """ |
| if not name: |
| raise ValueError("Plugin name must not be empty.") |
|
|
| |
| for bad in ("/", "\\", ".."): |
| if bad in name: |
| raise ValueError(f"Invalid plugin name '{name}': must not contain '{bad}'.") |
|
|
| target = (plugins_dir / name).resolve() |
| plugins_resolved = plugins_dir.resolve() |
|
|
| if ( |
| not str(target).startswith(str(plugins_resolved) + os.sep) |
| and target != plugins_resolved |
| ): |
| raise ValueError( |
| f"Invalid plugin name '{name}': resolves outside the plugins directory." |
| ) |
|
|
| return target |
|
|
|
|
| def _resolve_git_url(identifier: str) -> str: |
| """Turn an identifier into a cloneable Git URL. |
| |
| Accepted formats: |
| - Full URL: https://github.com/owner/repo.git |
| - Full URL: git@github.com:owner/repo.git |
| - Full URL: ssh://git@github.com/owner/repo.git |
| - Shorthand: owner/repo → https://github.com/owner/repo.git |
| |
| NOTE: ``http://`` and ``file://`` schemes are accepted but will trigger a |
| security warning at install time. |
| """ |
| |
| if identifier.startswith(("https://", "http://", "git@", "ssh://", "file://")): |
| return identifier |
|
|
| |
| parts = identifier.strip("/").split("/") |
| if len(parts) == 2: |
| owner, repo = parts |
| return f"https://github.com/{owner}/{repo}.git" |
|
|
| raise ValueError( |
| f"Invalid plugin identifier: '{identifier}'. " |
| "Use a Git URL or owner/repo shorthand." |
| ) |
|
|
|
|
| def _repo_name_from_url(url: str) -> str: |
| """Extract the repo name from a Git URL for the plugin directory name.""" |
| |
| name = url.rstrip("/") |
| if name.endswith(".git"): |
| name = name[:-4] |
| |
| name = name.rsplit("/", 1)[-1] |
| |
| if ":" in name: |
| name = name.rsplit(":", 1)[-1].rsplit("/", 1)[-1] |
| return name |
|
|
|
|
| def _read_manifest(plugin_dir: Path) -> dict: |
| """Read plugin.yaml and return the parsed dict, or empty dict.""" |
| manifest_file = plugin_dir / "plugin.yaml" |
| if not manifest_file.exists(): |
| return {} |
| try: |
| import yaml |
|
|
| with open(manifest_file) as f: |
| return yaml.safe_load(f) or {} |
| except Exception as e: |
| logger.warning("Failed to read plugin.yaml in %s: %s", plugin_dir, e) |
| return {} |
|
|
|
|
| def _copy_example_files(plugin_dir: Path, console) -> None: |
| """Copy any .example files to their real names if they don't already exist. |
| |
| For example, ``config.yaml.example`` becomes ``config.yaml``. |
| Skips files that already exist to avoid overwriting user config on reinstall. |
| """ |
| for example_file in plugin_dir.glob("*.example"): |
| real_name = example_file.stem |
| real_path = plugin_dir / real_name |
| if not real_path.exists(): |
| try: |
| shutil.copy2(example_file, real_path) |
| console.print( |
| f"[dim] Created {real_name} from {example_file.name}[/dim]" |
| ) |
| except OSError as e: |
| console.print( |
| f"[yellow]Warning:[/yellow] Failed to copy {example_file.name}: {e}" |
| ) |
|
|
|
|
| def _display_after_install(plugin_dir: Path, identifier: str) -> None: |
| """Show after-install.md if it exists, otherwise a default message.""" |
| from rich.console import Console |
| from rich.markdown import Markdown |
| from rich.panel import Panel |
|
|
| console = Console() |
| after_install = plugin_dir / "after-install.md" |
|
|
| if after_install.exists(): |
| content = after_install.read_text(encoding="utf-8") |
| md = Markdown(content) |
| console.print() |
| console.print(Panel(md, border_style="green", expand=False)) |
| console.print() |
| else: |
| console.print() |
| console.print( |
| Panel( |
| f"[green bold]Plugin installed:[/] {identifier}\n" |
| f"[dim]Location:[/] {plugin_dir}", |
| border_style="green", |
| title="✓ Installed", |
| expand=False, |
| ) |
| ) |
| console.print() |
|
|
|
|
| def _display_removed(name: str, plugins_dir: Path) -> None: |
| """Show confirmation after removing a plugin.""" |
| from rich.console import Console |
|
|
| console = Console() |
| console.print() |
| console.print(f"[red]✗[/red] Plugin [bold]{name}[/bold] removed from {plugins_dir}") |
| console.print() |
|
|
|
|
| def _require_installed_plugin(name: str, plugins_dir: Path, console) -> Path: |
| """Return the plugin path if it exists, or exit with an error listing installed plugins.""" |
| target = _sanitize_plugin_name(name, plugins_dir) |
| if not target.exists(): |
| installed = ", ".join(d.name for d in plugins_dir.iterdir() if d.is_dir()) or "(none)" |
| console.print( |
| f"[red]Error:[/red] Plugin '{name}' not found in {plugins_dir}.\n" |
| f"Installed plugins: {installed}" |
| ) |
| sys.exit(1) |
| return target |
|
|
|
|
| |
| |
| |
|
|
|
|
| def cmd_install(identifier: str, force: bool = False) -> None: |
| """Install a plugin from a Git URL or owner/repo shorthand.""" |
| import tempfile |
| from rich.console import Console |
|
|
| console = Console() |
|
|
| try: |
| git_url = _resolve_git_url(identifier) |
| except ValueError as e: |
| console.print(f"[red]Error:[/red] {e}") |
| sys.exit(1) |
|
|
| |
| if git_url.startswith("http://") or git_url.startswith("file://"): |
| console.print( |
| "[yellow]Warning:[/yellow] Using insecure/local URL scheme. " |
| "Consider using https:// or git@ for production installs." |
| ) |
|
|
| plugins_dir = _plugins_dir() |
|
|
| |
| with tempfile.TemporaryDirectory() as tmp: |
| tmp_target = Path(tmp) / "plugin" |
| console.print(f"[dim]Cloning {git_url}...[/dim]") |
|
|
| try: |
| result = subprocess.run( |
| ["git", "clone", "--depth", "1", git_url, str(tmp_target)], |
| capture_output=True, |
| text=True, |
| timeout=60, |
| ) |
| except FileNotFoundError: |
| console.print("[red]Error:[/red] git is not installed or not in PATH.") |
| sys.exit(1) |
| except subprocess.TimeoutExpired: |
| console.print("[red]Error:[/red] Git clone timed out after 60 seconds.") |
| sys.exit(1) |
|
|
| if result.returncode != 0: |
| console.print( |
| f"[red]Error:[/red] Git clone failed:\n{result.stderr.strip()}" |
| ) |
| sys.exit(1) |
|
|
| |
| manifest = _read_manifest(tmp_target) |
| plugin_name = manifest.get("name") or _repo_name_from_url(git_url) |
|
|
| |
| try: |
| target = _sanitize_plugin_name(plugin_name, plugins_dir) |
| except ValueError as e: |
| console.print(f"[red]Error:[/red] {e}") |
| sys.exit(1) |
|
|
| |
| mv = manifest.get("manifest_version") |
| if mv is not None: |
| try: |
| mv_int = int(mv) |
| except (ValueError, TypeError): |
| console.print( |
| f"[red]Error:[/red] Plugin '{plugin_name}' has invalid " |
| f"manifest_version '{mv}' (expected an integer)." |
| ) |
| sys.exit(1) |
| if mv_int > _SUPPORTED_MANIFEST_VERSION: |
| console.print( |
| f"[red]Error:[/red] Plugin '{plugin_name}' requires manifest_version " |
| f"{mv}, but this installer only supports up to {_SUPPORTED_MANIFEST_VERSION}.\n" |
| f"Run [bold]hermes update[/bold] to get a newer installer." |
| ) |
| sys.exit(1) |
|
|
| if target.exists(): |
| if not force: |
| console.print( |
| f"[red]Error:[/red] Plugin '{plugin_name}' already exists at {target}.\n" |
| f"Use [bold]--force[/bold] to remove and reinstall, or " |
| f"[bold]hermes plugins update {plugin_name}[/bold] to pull latest." |
| ) |
| sys.exit(1) |
| console.print(f"[dim] Removing existing {plugin_name}...[/dim]") |
| shutil.rmtree(target) |
|
|
| |
| shutil.move(str(tmp_target), str(target)) |
|
|
| |
| if not (target / "plugin.yaml").exists() and not (target / "__init__.py").exists(): |
| console.print( |
| f"[yellow]Warning:[/yellow] {plugin_name} doesn't contain plugin.yaml " |
| f"or __init__.py. It may not be a valid Hermes plugin." |
| ) |
|
|
| |
| _copy_example_files(target, console) |
|
|
| _display_after_install(target, identifier) |
|
|
| console.print("[dim]Restart the gateway for the plugin to take effect:[/dim]") |
| console.print("[dim] hermes gateway restart[/dim]") |
| console.print() |
|
|
|
|
| def cmd_update(name: str) -> None: |
| """Update an installed plugin by pulling latest from its git remote.""" |
| from rich.console import Console |
|
|
| console = Console() |
| plugins_dir = _plugins_dir() |
|
|
| try: |
| target = _require_installed_plugin(name, plugins_dir, console) |
| except ValueError as e: |
| console.print(f"[red]Error:[/red] {e}") |
| sys.exit(1) |
|
|
| if not (target / ".git").exists(): |
| console.print( |
| f"[red]Error:[/red] Plugin '{name}' was not installed from git " |
| f"(no .git directory). Cannot update." |
| ) |
| sys.exit(1) |
|
|
| console.print(f"[dim]Updating {name}...[/dim]") |
|
|
| try: |
| result = subprocess.run( |
| ["git", "pull", "--ff-only"], |
| capture_output=True, |
| text=True, |
| timeout=60, |
| cwd=str(target), |
| ) |
| except FileNotFoundError: |
| console.print("[red]Error:[/red] git is not installed or not in PATH.") |
| sys.exit(1) |
| except subprocess.TimeoutExpired: |
| console.print("[red]Error:[/red] Git pull timed out after 60 seconds.") |
| sys.exit(1) |
|
|
| if result.returncode != 0: |
| console.print(f"[red]Error:[/red] Git pull failed:\n{result.stderr.strip()}") |
| sys.exit(1) |
|
|
| |
| _copy_example_files(target, console) |
|
|
| output = result.stdout.strip() |
| if "Already up to date" in output: |
| console.print( |
| f"[green]✓[/green] Plugin [bold]{name}[/bold] is already up to date." |
| ) |
| else: |
| console.print(f"[green]✓[/green] Plugin [bold]{name}[/bold] updated.") |
| console.print(f"[dim]{output}[/dim]") |
|
|
|
|
| def cmd_remove(name: str) -> None: |
| """Remove an installed plugin by name.""" |
| from rich.console import Console |
|
|
| console = Console() |
| plugins_dir = _plugins_dir() |
|
|
| try: |
| target = _require_installed_plugin(name, plugins_dir, console) |
| except ValueError as e: |
| console.print(f"[red]Error:[/red] {e}") |
| sys.exit(1) |
|
|
| shutil.rmtree(target) |
| _display_removed(name, plugins_dir) |
|
|
|
|
| def cmd_list() -> None: |
| """List installed plugins.""" |
| from rich.console import Console |
| from rich.table import Table |
|
|
| try: |
| import yaml |
| except ImportError: |
| yaml = None |
|
|
| console = Console() |
| plugins_dir = _plugins_dir() |
|
|
| dirs = sorted(d for d in plugins_dir.iterdir() if d.is_dir()) |
| if not dirs: |
| console.print("[dim]No plugins installed.[/dim]") |
| console.print(f"[dim]Install with:[/dim] hermes plugins install owner/repo") |
| return |
|
|
| table = Table(title="Installed Plugins", show_lines=False) |
| table.add_column("Name", style="bold") |
| table.add_column("Version", style="dim") |
| table.add_column("Description") |
| table.add_column("Source", style="dim") |
|
|
| for d in dirs: |
| manifest_file = d / "plugin.yaml" |
| name = d.name |
| version = "" |
| description = "" |
| source = "local" |
|
|
| if manifest_file.exists() and yaml: |
| try: |
| with open(manifest_file) as f: |
| manifest = yaml.safe_load(f) or {} |
| name = manifest.get("name", d.name) |
| version = manifest.get("version", "") |
| description = manifest.get("description", "") |
| except Exception: |
| pass |
|
|
| |
| if (d / ".git").exists(): |
| source = "git" |
|
|
| table.add_row(name, str(version), description, source) |
|
|
| console.print() |
| console.print(table) |
| console.print() |
|
|
|
|
| def plugins_command(args) -> None: |
| """Dispatch hermes plugins subcommands.""" |
| action = getattr(args, "plugins_action", None) |
|
|
| if action == "install": |
| cmd_install(args.identifier, force=getattr(args, "force", False)) |
| elif action == "update": |
| cmd_update(args.name) |
| elif action in ("remove", "rm", "uninstall"): |
| cmd_remove(args.name) |
| elif action in ("list", "ls") or action is None: |
| cmd_list() |
| else: |
| from rich.console import Console |
|
|
| Console().print(f"[red]Unknown plugins action: {action}[/red]") |
| sys.exit(1) |
|
|