mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
feat(plugins): catalog CLI surface — search/browse/info/install/update/doctor
- install: catalog names resolve to the pinned SHA (ref= checkout), print tier + capability summary before the enable prompt, and write a .hermes-catalog.json provenance sidecar; raw git URLs get a 'custom (unreviewed) source' banner; --allow-removed loudly bypasses the removed blocklist (skip_removed_check=True) - update: sidecar installs compare against the current catalog pin and force-reinstall at the new SHA (enabled state preserved); plain git installs keep the git-pull flow - list: catalog:<tier>@<shaShort> annotation + red 'REMOVED from catalog' lines for blocklisted installs (table and --json) - search/browse/info: live-index catalog tables and full entry detail with removed-list warnings - validate: human ✓/✗ output, --json for CI, exit 0/1 - doctor: per-plugin manifest/enabled/load-error/env/requires_hermes/ provenance/pin/removed diagnosis, compact table or single-name detail
This commit is contained in:
parent
44624631bf
commit
f1b30414d5
2 changed files with 645 additions and 7 deletions
|
|
@ -613,12 +613,98 @@ def _install_plugin_core(
|
|||
return target, installed_manifest, installed_name
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Catalog integration helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CATALOG_SIDECAR = ".hermes-catalog.json"
|
||||
|
||||
|
||||
def _looks_like_catalog_name(identifier: str) -> bool:
|
||||
"""True when *identifier* could be a catalog entry name (not a URL/shorthand)."""
|
||||
if not identifier or "/" in identifier or "\\" in identifier:
|
||||
return False
|
||||
if identifier.startswith(("https://", "http://", "git@", "ssh://", "file://")):
|
||||
return False
|
||||
from hermes_cli.plugin_catalog import _NAME_RE
|
||||
|
||||
return bool(_NAME_RE.match(identifier))
|
||||
|
||||
|
||||
def _get_live_catalog_entry(name: str):
|
||||
"""Look up *name* in the live-refreshed catalog (falls back in-tree)."""
|
||||
from hermes_cli.plugin_catalog import load_catalog_live
|
||||
|
||||
for entry in load_catalog_live():
|
||||
if entry.name == name:
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
def _catalog_install_identifier(entry) -> str:
|
||||
"""Build the ``_install_plugin_core`` identifier for a catalog entry.
|
||||
|
||||
Uses the explicit ``#subdir`` fragment form understood by
|
||||
:func:`_resolve_git_url` when the entry lives in a repo subdirectory.
|
||||
"""
|
||||
if entry.subdir:
|
||||
return f"{entry.repo}#{entry.subdir}"
|
||||
return entry.repo
|
||||
|
||||
|
||||
def _write_catalog_sidecar(target: Path, entry) -> None:
|
||||
"""Record catalog provenance in ``.hermes-catalog.json`` inside *target*.
|
||||
|
||||
The sidecar is how ``update``/``list``/``doctor`` know the plugin came
|
||||
from the catalog (and at which pin).
|
||||
"""
|
||||
import datetime
|
||||
|
||||
sidecar = {
|
||||
"catalog_name": entry.name,
|
||||
"repo": entry.repo,
|
||||
"sha": entry.sha,
|
||||
"installed_at": datetime.datetime.now(datetime.timezone.utc)
|
||||
.isoformat(timespec="seconds")
|
||||
.replace("+00:00", "Z"),
|
||||
"tier": entry.tier,
|
||||
}
|
||||
try:
|
||||
(target / _CATALOG_SIDECAR).write_text(
|
||||
json.dumps(sidecar, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to write catalog sidecar in %s: %s", target, exc)
|
||||
|
||||
|
||||
def _read_catalog_sidecar(plugin_dir: Path) -> Optional[dict]:
|
||||
"""Return the parsed catalog provenance sidecar, or None."""
|
||||
path = plugin_dir / _CATALOG_SIDECAR
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
# Unreadable / corrupt sidecar — treat as a non-catalog install.
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def cmd_install(
|
||||
identifier: str,
|
||||
force: bool = False,
|
||||
enable: Optional[bool] = None,
|
||||
allow_removed: bool = False,
|
||||
) -> None:
|
||||
"""Install a plugin from a Git URL or owner/repo shorthand.
|
||||
"""Install a plugin from the catalog, a Git URL, or owner/repo shorthand.
|
||||
|
||||
When *identifier* matches a catalog entry name (and is not a URL or
|
||||
``owner/repo`` shorthand), the install resolves to the entry's pinned
|
||||
commit SHA and records catalog provenance in a ``.hermes-catalog.json``
|
||||
sidecar. Raw git URLs keep the direct flow but are flagged as custom
|
||||
(unreviewed) sources.
|
||||
|
||||
``allow_removed=True`` bypasses the removed-blocklist check (loudly).
|
||||
|
||||
After install, prompt "Enable now? [y/N]" unless *enable* is provided
|
||||
(True = auto-enable without prompting, False = install disabled).
|
||||
|
|
@ -627,6 +713,51 @@ def cmd_install(
|
|||
|
||||
console = Console()
|
||||
|
||||
entry = None
|
||||
if _looks_like_catalog_name(identifier):
|
||||
from hermes_cli.plugin_catalog import (
|
||||
entry_capability_summary,
|
||||
find_removed,
|
||||
)
|
||||
|
||||
entry = _get_live_catalog_entry(identifier)
|
||||
if entry is None:
|
||||
console.print(
|
||||
f"[red]Error:[/red] '{identifier}' is not in the Hermes "
|
||||
"plugin catalog and is not a Git URL or owner/repo "
|
||||
"shorthand.\n"
|
||||
"Browse available entries with `hermes plugins search`."
|
||||
)
|
||||
sys.exit(1)
|
||||
if not allow_removed:
|
||||
removed = find_removed(entry.name) or find_removed(entry.repo)
|
||||
if removed is not None:
|
||||
try:
|
||||
_raise_removed(removed)
|
||||
except PluginOperationError as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
sys.exit(1)
|
||||
console.print(
|
||||
f"[bold]{entry.name}[/bold] "
|
||||
f"[cyan]\\[{entry.tier}][/cyan] "
|
||||
f"[dim]pinned @ {entry.sha[:8]}[/dim]"
|
||||
)
|
||||
console.print(entry_capability_summary(entry))
|
||||
identifier = _catalog_install_identifier(entry)
|
||||
else:
|
||||
console.print(
|
||||
"[yellow]Warning:[/yellow] custom (unreviewed) source — "
|
||||
"not from the Hermes catalog."
|
||||
)
|
||||
|
||||
if allow_removed:
|
||||
console.print(
|
||||
"[bold red]WARNING:[/bold red] [red]--allow-removed set — "
|
||||
"skipping the removed-plugin blocklist check. This plugin may "
|
||||
"have been removed from the catalog for security reasons. "
|
||||
"Proceed at your own risk.[/red]"
|
||||
)
|
||||
|
||||
try:
|
||||
git_url, _subdir = _resolve_git_url(identifier)
|
||||
except ValueError as e:
|
||||
|
|
@ -648,11 +779,16 @@ def cmd_install(
|
|||
target, installed_manifest, installed_name = _install_plugin_core(
|
||||
identifier,
|
||||
force=force,
|
||||
ref=entry.sha if entry is not None else None,
|
||||
skip_removed_check=allow_removed,
|
||||
)
|
||||
except PluginOperationError as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
sys.exit(1)
|
||||
|
||||
if entry is not None:
|
||||
_write_catalog_sidecar(target, entry)
|
||||
|
||||
if not (target / "plugin.yaml").exists() and not (target / "plugin.yml").exists() and not (
|
||||
target / "__init__.py"
|
||||
).exists():
|
||||
|
|
@ -700,7 +836,13 @@ def cmd_install(
|
|||
|
||||
|
||||
def cmd_update(name: str) -> None:
|
||||
"""Update an installed plugin by pulling latest from its git remote."""
|
||||
"""Update an installed plugin.
|
||||
|
||||
Catalog installs (``.hermes-catalog.json`` sidecar present) are compared
|
||||
against the current catalog pin: if the pinned SHA changed, the plugin is
|
||||
force-reinstalled at the new pin (enabled state preserved). Plain git
|
||||
installs keep the existing ``git pull`` behavior.
|
||||
"""
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
|
@ -712,6 +854,11 @@ def cmd_update(name: str) -> None:
|
|||
console.print(f"[red]Error:[/red] {e}")
|
||||
sys.exit(1)
|
||||
|
||||
sidecar = _read_catalog_sidecar(target)
|
||||
if sidecar is not None and sidecar.get("catalog_name"):
|
||||
_update_catalog_plugin(name, target, sidecar, console)
|
||||
return
|
||||
|
||||
if not (target / ".git").exists():
|
||||
console.print(
|
||||
f"[red]Error:[/red] Plugin '{name}' was not installed from git "
|
||||
|
|
@ -739,6 +886,55 @@ def cmd_update(name: str) -> None:
|
|||
console.print(f"[dim]{out}[/dim]")
|
||||
|
||||
|
||||
def _update_catalog_plugin(name: str, target: Path, sidecar: dict, console) -> None:
|
||||
"""Re-pin a catalog-installed plugin to the current catalog SHA."""
|
||||
catalog_name = str(sidecar.get("catalog_name") or name)
|
||||
entry = _get_live_catalog_entry(catalog_name)
|
||||
if entry is None:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Plugin '{catalog_name}' is no longer in the "
|
||||
"catalog — it may have been removed. Check "
|
||||
"`hermes plugins doctor` and the removed blocklist."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
installed_sha = str(sidecar.get("sha") or "").strip().lower()
|
||||
if installed_sha == entry.sha:
|
||||
console.print(
|
||||
f"[green]✓[/green] Plugin [bold]{catalog_name}[/bold] is "
|
||||
f"already at catalog pin ({entry.sha[:8]})."
|
||||
)
|
||||
return
|
||||
|
||||
console.print(
|
||||
f"[dim]Updating {catalog_name} to catalog pin:[/dim] "
|
||||
f"{installed_sha[:8] or '(unknown)'} → {entry.sha[:8]}"
|
||||
)
|
||||
|
||||
# Preserve enabled state across the force reinstall.
|
||||
was_enabled = _get_enabled_set()
|
||||
|
||||
try:
|
||||
new_target, _manifest, _installed_name = _install_plugin_core(
|
||||
_catalog_install_identifier(entry),
|
||||
force=True,
|
||||
ref=entry.sha,
|
||||
)
|
||||
except PluginOperationError as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
sys.exit(1)
|
||||
|
||||
_write_catalog_sidecar(new_target, entry)
|
||||
# Restore the pre-update enabled/disabled state verbatim (the reinstall
|
||||
# itself never touches it, but be explicit in case core ever does).
|
||||
_save_enabled_set(was_enabled)
|
||||
|
||||
console.print(
|
||||
f"[green]✓[/green] Plugin [bold]{catalog_name}[/bold] updated to "
|
||||
f"{entry.sha[:8]}."
|
||||
)
|
||||
|
||||
|
||||
def cmd_remove(name: str) -> None:
|
||||
"""Remove an installed plugin by name."""
|
||||
from rich.console import Console
|
||||
|
|
@ -1188,6 +1384,44 @@ def _filter_plugin_entries(entries: list, args: Any, enabled: set, disabled: set
|
|||
return filtered
|
||||
|
||||
|
||||
def _catalog_annotation(dir_path) -> Optional[str]:
|
||||
"""Return ``catalog:<tier>@<shaShort>`` for a catalog install, else None."""
|
||||
if not dir_path:
|
||||
return None
|
||||
try:
|
||||
sidecar = _read_catalog_sidecar(Path(dir_path))
|
||||
except Exception:
|
||||
return None
|
||||
if not sidecar or not sidecar.get("catalog_name"):
|
||||
return None
|
||||
tier = str(sidecar.get("tier") or "community")
|
||||
sha = str(sidecar.get("sha") or "")
|
||||
return f"catalog:{tier}@{sha[:8]}"
|
||||
|
||||
|
||||
def _removed_annotation(name: str, dir_path) -> Optional[str]:
|
||||
"""Return the removed-blocklist reason when *name* matches, else None."""
|
||||
try:
|
||||
from hermes_cli.plugin_catalog import find_removed
|
||||
except Exception:
|
||||
return None
|
||||
candidates = [name]
|
||||
if dir_path:
|
||||
try:
|
||||
sidecar = _read_catalog_sidecar(Path(dir_path))
|
||||
except Exception:
|
||||
sidecar = None
|
||||
if sidecar:
|
||||
candidates.extend(
|
||||
str(v) for v in (sidecar.get("catalog_name"), sidecar.get("repo")) if v
|
||||
)
|
||||
for candidate in candidates:
|
||||
removed = find_removed(candidate)
|
||||
if removed is not None:
|
||||
return removed.reason or "no reason recorded"
|
||||
return None
|
||||
|
||||
|
||||
def cmd_list(args: Any | None = None) -> None:
|
||||
"""List all plugins (bundled + user) with enabled/disabled state."""
|
||||
from rich.console import Console
|
||||
|
|
@ -1205,16 +1439,22 @@ def cmd_list(args: Any | None = None) -> None:
|
|||
entries = _filter_plugin_entries(entries, args, enabled, disabled)
|
||||
|
||||
if getattr(args, "json", False):
|
||||
payload = [
|
||||
{
|
||||
payload = []
|
||||
for name, version, description, source, _dir, key in entries:
|
||||
row = {
|
||||
"name": name,
|
||||
"status": _plugin_status(name, enabled, disabled, key=key),
|
||||
"version": str(version),
|
||||
"description": description,
|
||||
"source": source,
|
||||
}
|
||||
for name, version, description, source, _dir, key in entries
|
||||
]
|
||||
catalog = _catalog_annotation(_dir)
|
||||
if catalog:
|
||||
row["catalog"] = catalog
|
||||
removed_reason = _removed_annotation(name, _dir)
|
||||
if removed_reason is not None:
|
||||
row["removed"] = removed_reason
|
||||
payload.append(row)
|
||||
print(json.dumps(payload, indent=2))
|
||||
return
|
||||
|
||||
|
|
@ -1235,6 +1475,7 @@ def cmd_list(args: Any | None = None) -> None:
|
|||
table.add_column("Description")
|
||||
table.add_column("Source", style="dim")
|
||||
|
||||
removed_lines: list[str] = []
|
||||
for name, version, description, source, _dir, key in entries:
|
||||
status_name = _plugin_status(name, enabled, disabled, key=key)
|
||||
if status_name == "disabled":
|
||||
|
|
@ -1243,10 +1484,20 @@ def cmd_list(args: Any | None = None) -> None:
|
|||
status = "[green]enabled[/green]"
|
||||
else:
|
||||
status = "[yellow]not enabled[/yellow]"
|
||||
table.add_row(name, status, str(version), description, source)
|
||||
catalog = _catalog_annotation(_dir)
|
||||
source_label = f"{source} [cyan]{catalog}[/cyan]" if catalog else source
|
||||
table.add_row(name, status, str(version), description, source_label)
|
||||
removed_reason = _removed_annotation(name, _dir)
|
||||
if removed_reason is not None:
|
||||
removed_lines.append(
|
||||
f"[red bold]✗ {name} — REMOVED from catalog: "
|
||||
f"{removed_reason}[/red bold]"
|
||||
)
|
||||
|
||||
console.print()
|
||||
console.print(table)
|
||||
for line in removed_lines:
|
||||
console.print(line)
|
||||
console.print()
|
||||
console.print("[dim]Compact view:[/dim] hermes plugins list --plain --no-bundled")
|
||||
console.print("[dim]Interactive toggle:[/dim] hermes plugins")
|
||||
|
|
@ -1254,6 +1505,337 @@ def cmd_list(args: Any | None = None) -> None:
|
|||
console.print("[dim]Plugins are opt-in by default — only 'enabled' plugins load.[/dim]")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Catalog commands — search / browse / info / validate / doctor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _entry_capability_counts(entry) -> str:
|
||||
"""Compact capability summary like ``2 tools, 1 hook`` for table rows."""
|
||||
caps = entry.capabilities
|
||||
parts: list[str] = []
|
||||
for count, singular in (
|
||||
(len(caps.provides_tools), "tool"),
|
||||
(len(caps.provides_hooks), "hook"),
|
||||
(len(caps.provides_middleware), "middleware"),
|
||||
):
|
||||
if count:
|
||||
plural = "" if count == 1 or singular == "middleware" else "s"
|
||||
parts.append(f"{count} {singular}{plural}")
|
||||
if caps.requires_env:
|
||||
parts.append(f"{len(caps.requires_env)} env")
|
||||
return ", ".join(parts) or "—"
|
||||
|
||||
|
||||
def _render_catalog_entries(entries, console) -> None:
|
||||
"""Render catalog entries as the shared search/browse Rich table."""
|
||||
from rich.table import Table
|
||||
|
||||
table = Table(title="Hermes Plugin Catalog", show_lines=False)
|
||||
table.add_column("Name", style="bold")
|
||||
table.add_column("Tier")
|
||||
table.add_column("Description")
|
||||
table.add_column("Pinned", style="dim")
|
||||
table.add_column("Capabilities", style="dim")
|
||||
|
||||
for entry in entries:
|
||||
tier = (
|
||||
"[cyan]official[/cyan]"
|
||||
if entry.tier == "official"
|
||||
else "[magenta]community[/magenta]"
|
||||
)
|
||||
description = entry.description
|
||||
if len(description) > 60:
|
||||
description = description[:57] + "..."
|
||||
table.add_row(
|
||||
entry.name,
|
||||
tier,
|
||||
description,
|
||||
entry.sha[:8],
|
||||
_entry_capability_counts(entry),
|
||||
)
|
||||
|
||||
console.print()
|
||||
console.print(table)
|
||||
console.print()
|
||||
console.print("[dim]Details:[/dim] hermes plugins info <name>")
|
||||
console.print("[dim]Install:[/dim] hermes plugins install <name>")
|
||||
|
||||
|
||||
def cmd_search(query: str = "") -> None:
|
||||
"""Search the plugin catalog (live index when reachable)."""
|
||||
from rich.console import Console
|
||||
|
||||
from hermes_cli.plugin_catalog import filter_entries, load_catalog_live
|
||||
|
||||
console = Console()
|
||||
entries = filter_entries(load_catalog_live(), query)
|
||||
if not entries:
|
||||
if query:
|
||||
console.print(
|
||||
f"[dim]No catalog entries match '{query}'. "
|
||||
"Browse everything with `hermes plugins browse`.[/dim]"
|
||||
)
|
||||
else:
|
||||
console.print("[dim]No catalog entries available.[/dim]")
|
||||
return
|
||||
_render_catalog_entries(entries, console)
|
||||
|
||||
|
||||
def cmd_browse() -> None:
|
||||
"""List every plugin catalog entry."""
|
||||
cmd_search("")
|
||||
|
||||
|
||||
def cmd_info(name: str) -> None:
|
||||
"""Show the full catalog entry for *name*."""
|
||||
from rich.console import Console
|
||||
|
||||
from hermes_cli.plugin_catalog import find_removed
|
||||
|
||||
console = Console()
|
||||
entry = _get_live_catalog_entry(name)
|
||||
if entry is None:
|
||||
console.print(
|
||||
f"[red]Error:[/red] '{name}' is not in the plugin catalog. "
|
||||
"Browse entries with `hermes plugins search`."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
caps = entry.capabilities
|
||||
console.print()
|
||||
console.print(f"[bold]{entry.name}[/bold] [cyan]\\[{entry.tier}][/cyan]")
|
||||
if entry.description:
|
||||
console.print(entry.description)
|
||||
console.print()
|
||||
console.print(f"[dim]Repo:[/dim] {entry.repo}")
|
||||
if entry.subdir:
|
||||
console.print(f"[dim]Subdir:[/dim] {entry.subdir}")
|
||||
console.print(f"[dim]Pinned SHA:[/dim] {entry.sha}")
|
||||
console.print(f"[dim]Maintainer:[/dim] {entry.maintainer}")
|
||||
if entry.requires_hermes:
|
||||
console.print(f"[dim]Requires:[/dim] hermes {entry.requires_hermes}")
|
||||
if entry.platforms:
|
||||
console.print(f"[dim]Platforms:[/dim] {', '.join(entry.platforms)}")
|
||||
if entry.docs_url:
|
||||
console.print(f"[dim]Docs:[/dim] {entry.docs_url}")
|
||||
console.print()
|
||||
console.print(f"[dim]Tools:[/dim] {', '.join(caps.provides_tools) or '(none)'}")
|
||||
console.print(f"[dim]Hooks:[/dim] {', '.join(caps.provides_hooks) or '(none)'}")
|
||||
console.print(f"[dim]Middleware:[/dim] {', '.join(caps.provides_middleware) or '(none)'}")
|
||||
console.print(f"[dim]Env vars:[/dim] {', '.join(caps.requires_env) or '(none)'}")
|
||||
console.print()
|
||||
|
||||
removed = find_removed(entry.name) or find_removed(entry.repo)
|
||||
if removed is not None:
|
||||
detail = removed.reason or "no reason recorded"
|
||||
if removed.date:
|
||||
detail += f" (removed {removed.date})"
|
||||
console.print(
|
||||
f"[red bold]✗ REMOVED from catalog: {detail}[/red bold]"
|
||||
)
|
||||
console.print()
|
||||
|
||||
console.print(f"[dim]Install:[/dim] hermes plugins install {entry.name}")
|
||||
console.print()
|
||||
|
||||
|
||||
def cmd_validate(path: str, as_json: bool = False) -> None:
|
||||
"""Validate a plugin directory for catalog admission. Exits 0/1."""
|
||||
from rich.console import Console
|
||||
|
||||
from hermes_cli.plugin_validate import validate_plugin_dir
|
||||
|
||||
report = validate_plugin_dir(Path(path))
|
||||
|
||||
if as_json:
|
||||
print(json.dumps(report.to_dict(), indent=2))
|
||||
sys.exit(report.exit_code)
|
||||
|
||||
console = Console()
|
||||
console.print()
|
||||
for name, ok, detail in report.checks:
|
||||
mark = "[green]✓[/green]" if ok else "[red]✗[/red]"
|
||||
line = f"{mark} {name}"
|
||||
if detail:
|
||||
line += f" [dim]— {detail}[/dim]"
|
||||
console.print(line)
|
||||
for warning in report.warnings:
|
||||
console.print(f"[yellow]⚠ {warning}[/yellow]")
|
||||
console.print()
|
||||
if report.ok:
|
||||
console.print("[green bold]Validation passed.[/green bold]")
|
||||
else:
|
||||
console.print("[red bold]Validation failed.[/red bold]")
|
||||
sys.exit(report.exit_code)
|
||||
|
||||
|
||||
def _runtime_load_errors() -> dict[str, str]:
|
||||
"""Return ``{plugin_key: error}`` from a fresh PluginManager scan.
|
||||
|
||||
Uses the idempotent ``discover_plugins()`` path so we don't need a full
|
||||
agent boot; failures are non-fatal (doctor still reports what it can).
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins, get_plugin_manager
|
||||
|
||||
discover_plugins()
|
||||
manager = get_plugin_manager()
|
||||
return {
|
||||
key: loaded.error
|
||||
for key, loaded in manager._plugins.items()
|
||||
if loaded.error
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.debug("doctor: runtime plugin scan failed: %s", exc)
|
||||
return {}
|
||||
|
||||
|
||||
def _doctor_plugin_report(name: str, dir_path: Path, key: str,
|
||||
enabled: set, disabled: set,
|
||||
load_errors: dict[str, str]) -> dict:
|
||||
"""Collect doctor facts for one installed plugin directory."""
|
||||
from hermes_cli.plugins import _running_hermes_version, _version_satisfies
|
||||
|
||||
manifest = _read_manifest(dir_path)
|
||||
facts: dict[str, Any] = {
|
||||
"name": name,
|
||||
"manifest_ok": bool(manifest.get("name")),
|
||||
"status": _plugin_status(name, enabled, disabled, key=key),
|
||||
"load_error": load_errors.get(key) or load_errors.get(name) or "",
|
||||
"missing_env": _missing_requires_env_names(manifest),
|
||||
"requires_hermes": "",
|
||||
"catalog": "",
|
||||
"pin": "",
|
||||
"removed": "",
|
||||
}
|
||||
|
||||
spec = str(manifest.get("requires_hermes") or "").strip()
|
||||
if spec:
|
||||
current = _running_hermes_version()
|
||||
if _version_satisfies(spec, current):
|
||||
facts["requires_hermes"] = f"{spec} ✓"
|
||||
else:
|
||||
facts["requires_hermes"] = f"{spec} ✗ (running {current})"
|
||||
|
||||
sidecar = _read_catalog_sidecar(dir_path)
|
||||
if sidecar and sidecar.get("catalog_name"):
|
||||
tier = str(sidecar.get("tier") or "community")
|
||||
sha = str(sidecar.get("sha") or "")
|
||||
facts["catalog"] = f"catalog:{tier}@{sha[:8]}"
|
||||
entry = _get_live_catalog_entry(str(sidecar["catalog_name"]))
|
||||
if entry is None:
|
||||
facts["pin"] = "entry gone from catalog"
|
||||
elif entry.sha != sha:
|
||||
facts["pin"] = (
|
||||
f"behind catalog pin ({sha[:8]} → {entry.sha[:8]}) — "
|
||||
"run `hermes plugins update`"
|
||||
)
|
||||
else:
|
||||
facts["pin"] = "at catalog pin"
|
||||
|
||||
removed_reason = _removed_annotation(name, dir_path)
|
||||
if removed_reason is not None:
|
||||
facts["removed"] = removed_reason
|
||||
return facts
|
||||
|
||||
|
||||
def cmd_doctor(name: Optional[str] = None) -> None:
|
||||
"""Diagnose installed plugins (or a single one when *name* given)."""
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
plugins_dir = _plugins_dir()
|
||||
installed = [
|
||||
(d.name, d) for d in sorted(plugins_dir.iterdir()) if d.is_dir()
|
||||
]
|
||||
if name is not None:
|
||||
installed = [(n, d) for n, d in installed if n == name]
|
||||
if not installed:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Plugin '{name}' not found in {plugins_dir}."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
if not installed:
|
||||
console.print("[dim]No plugins installed under[/dim] "
|
||||
f"{plugins_dir}")
|
||||
return
|
||||
|
||||
enabled = _get_enabled_set()
|
||||
disabled = _get_disabled_set()
|
||||
load_errors = _runtime_load_errors()
|
||||
|
||||
reports = [
|
||||
_doctor_plugin_report(n, d, n, enabled, disabled, load_errors)
|
||||
for n, d in installed
|
||||
]
|
||||
|
||||
if name is not None:
|
||||
facts = reports[0]
|
||||
console.print()
|
||||
console.print(f"[bold]{facts['name']}[/bold]")
|
||||
console.print(
|
||||
f"[dim]Manifest:[/dim] "
|
||||
+ ("[green]ok[/green]" if facts["manifest_ok"]
|
||||
else "[red]missing/invalid plugin.yaml[/red]")
|
||||
)
|
||||
console.print(f"[dim]Status:[/dim] {facts['status']}")
|
||||
if facts["load_error"]:
|
||||
console.print(f"[dim]Load error:[/dim] [red]{facts['load_error']}[/red]")
|
||||
if facts["missing_env"]:
|
||||
console.print(
|
||||
f"[dim]Missing env:[/dim] [yellow]{', '.join(facts['missing_env'])}[/yellow]"
|
||||
)
|
||||
if facts["requires_hermes"]:
|
||||
console.print(f"[dim]Requires:[/dim] hermes {facts['requires_hermes']}")
|
||||
if facts["catalog"]:
|
||||
console.print(f"[dim]Provenance:[/dim] {facts['catalog']}")
|
||||
console.print(f"[dim]Pin:[/dim] {facts['pin']}")
|
||||
if facts["removed"]:
|
||||
console.print(
|
||||
f"[red bold]✗ REMOVED from catalog: {facts['removed']}[/red bold]"
|
||||
)
|
||||
console.print()
|
||||
return
|
||||
|
||||
table = Table(title="Plugin Doctor", show_lines=False)
|
||||
table.add_column("Name", style="bold")
|
||||
table.add_column("Manifest")
|
||||
table.add_column("Status")
|
||||
table.add_column("Issues")
|
||||
table.add_column("Catalog", style="dim")
|
||||
|
||||
for facts in reports:
|
||||
issues: list[str] = []
|
||||
if facts["load_error"]:
|
||||
issues.append(f"[red]{facts['load_error']}[/red]")
|
||||
if facts["missing_env"]:
|
||||
issues.append(
|
||||
f"[yellow]missing env: {', '.join(facts['missing_env'])}[/yellow]"
|
||||
)
|
||||
if facts["requires_hermes"] and "✗" in facts["requires_hermes"]:
|
||||
issues.append(f"[red]requires hermes {facts['requires_hermes']}[/red]")
|
||||
if facts["removed"]:
|
||||
issues.append(
|
||||
f"[red bold]REMOVED from catalog: {facts['removed']}[/red bold]"
|
||||
)
|
||||
if facts["pin"] and "behind" in facts["pin"]:
|
||||
issues.append(f"[yellow]{facts['pin']}[/yellow]")
|
||||
table.add_row(
|
||||
facts["name"],
|
||||
"[green]ok[/green]" if facts["manifest_ok"] else "[red]bad[/red]",
|
||||
facts["status"],
|
||||
"\n".join(issues) or "[dim]—[/dim]",
|
||||
facts["catalog"] or "[dim]—[/dim]",
|
||||
)
|
||||
|
||||
console.print()
|
||||
console.print(table)
|
||||
console.print()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider plugin discovery helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -2083,7 +2665,18 @@ def plugins_command(args) -> None:
|
|||
args.identifier,
|
||||
force=getattr(args, "force", False),
|
||||
enable=enable_arg,
|
||||
allow_removed=getattr(args, "allow_removed", False),
|
||||
)
|
||||
elif action == "search":
|
||||
cmd_search(getattr(args, "query", "") or "")
|
||||
elif action == "browse":
|
||||
cmd_browse()
|
||||
elif action == "info":
|
||||
cmd_info(args.name)
|
||||
elif action == "validate":
|
||||
cmd_validate(args.path, as_json=getattr(args, "json", False))
|
||||
elif action == "doctor":
|
||||
cmd_doctor(getattr(args, "name", None))
|
||||
elif action == "update":
|
||||
cmd_update(args.name)
|
||||
elif action in {"remove", "rm", "uninstall"}:
|
||||
|
|
|
|||
|
|
@ -42,6 +42,51 @@ def build_plugins_parser(subparsers, *, cmd_plugins: Callable) -> None:
|
|||
action="store_true",
|
||||
help="Install disabled (skip confirmation prompt); enable later with `hermes plugins enable <name>`",
|
||||
)
|
||||
plugins_install.add_argument(
|
||||
"--allow-removed",
|
||||
action="store_true",
|
||||
help="DANGEROUS: bypass the catalog removed-plugin blocklist check",
|
||||
)
|
||||
|
||||
plugins_search = plugins_subparsers.add_parser(
|
||||
"search", help="Search the Hermes plugin catalog"
|
||||
)
|
||||
plugins_search.add_argument(
|
||||
"query",
|
||||
nargs="?",
|
||||
default="",
|
||||
help="Substring to match against entry names, descriptions, and tools",
|
||||
)
|
||||
|
||||
plugins_subparsers.add_parser(
|
||||
"browse", help="Browse every plugin catalog entry"
|
||||
)
|
||||
|
||||
plugins_info = plugins_subparsers.add_parser(
|
||||
"info", help="Show full catalog details for an entry"
|
||||
)
|
||||
plugins_info.add_argument("name", help="Catalog entry name")
|
||||
|
||||
plugins_validate = plugins_subparsers.add_parser(
|
||||
"validate",
|
||||
help="Validate a plugin directory for catalog admission (CI gate)",
|
||||
)
|
||||
plugins_validate.add_argument("path", help="Path to the plugin directory")
|
||||
plugins_validate.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Print machine-readable JSON (for CI)",
|
||||
)
|
||||
|
||||
plugins_doctor = plugins_subparsers.add_parser(
|
||||
"doctor", help="Diagnose installed plugins"
|
||||
)
|
||||
plugins_doctor.add_argument(
|
||||
"name",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help="Plugin name to inspect in detail (default: all installed)",
|
||||
)
|
||||
|
||||
plugins_update = plugins_subparsers.add_parser(
|
||||
"update", help="Pull latest changes for an installed plugin"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue