Three categories of cleanup, all zero-behavioral-change:
1. F-strings without placeholders (154 fixes across 29 files)
- Converted f'...' to '...' where no {expression} was present
- Heaviest files: run_agent.py (24), cli.py (20), honcho_integration/cli.py (34)
2. Simplify defensive patterns in run_agent.py
- Added explicit self._is_anthropic_oauth = False in __init__ (before
the api_mode branch that conditionally sets it)
- Replaced 7x getattr(self, '_is_anthropic_oauth', False) with direct
self._is_anthropic_oauth (attribute always initialized now)
- Added _is_openrouter_url() and _is_anthropic_url() helper methods
- Replaced 3 inline 'openrouter' in self._base_url_lower checks
3. Remove dead code in small files
- hermes_cli/claw.py: removed unused 'total' computation
- tools/fuzzy_match.py: removed unused strip_indent() function and
pattern_stripped variable
Full test suite: 6184 passed, 0 failures
E2E PTY: banner clean, tool calls work, zero garbled ANSI
447 lines
15 KiB
Python
447 lines
15 KiB
Python
"""``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("[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)
|