Download hermes_cli/plugins_cmd.py from lenson78/hermes: direct link, hf CLI and curl.
- Browser
- Download file 14.9 kB
-
https://huggingface.co/spaces/lenson78/hermes/resolve/main/hermes_cli/plugins_cmd.py
- Command line
-
hf download hf://spaces/lenson78/hermes/hermes_cli/plugins_cmd.py
-
curl -L -o plugins_cmd.py https://huggingface.co/spaces/lenson78/hermes/resolve/main/hermes_cli/plugins_cmd.py
14.9 kB
| """``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__) | |
| # Minimum manifest version this installer understands. | |
| # Plugins may declare ``manifest_version: 1`` in plugin.yaml; | |
| # future breaking changes to the manifest schema bump this. | |
| _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.") | |
| # Reject obvious traversal characters | |
| 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. | |
| """ | |
| # Already a URL | |
| if identifier.startswith(("https://", "http://", "git@", "ssh://", "file://")): | |
| return identifier | |
| # owner/repo shorthand | |
| 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.""" | |
| # Strip trailing .git and slashes | |
| name = url.rstrip("/") | |
| if name.endswith(".git"): | |
| name = name[:-4] | |
| # Get last path component | |
| name = name.rsplit("/", 1)[-1] | |
| # Handle ssh-style urls: git@github.com:owner/repo | |
| 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 # e.g. "config.yaml" from "config.yaml.example" | |
| 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 | |
| # --------------------------------------------------------------------------- | |
| # Commands | |
| # --------------------------------------------------------------------------- | |
| 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) | |
| # Warn about insecure / local URL schemes | |
| 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() | |
| # Clone into a temp directory first so we can read plugin.yaml for the name | |
| 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) | |
| # Read manifest | |
| manifest = _read_manifest(tmp_target) | |
| plugin_name = manifest.get("name") or _repo_name_from_url(git_url) | |
| # Sanitize plugin name against path traversal | |
| try: | |
| target = _sanitize_plugin_name(plugin_name, plugins_dir) | |
| except ValueError as e: | |
| console.print(f"[red]Error:[/red] {e}") | |
| sys.exit(1) | |
| # Check manifest_version compatibility | |
| 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) | |
| # Move from temp to final location | |
| shutil.move(str(tmp_target), str(target)) | |
| # Validate it looks like a plugin | |
| 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 to their real names (e.g. config.yaml.example → config.yaml) | |
| _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 any new .example files | |
| _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 | |
| # Check if it's a git repo (installed via hermes plugins install) | |
| 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) | |