Files
hermes-agent/hermes_cli/subcommands/plugins.py
ethernet e4cc7f09d9 merge: integrate upstream catalog with PM publication
Keep upstream's reviewed catalog as the only plugin name index.
Catalog pins and custom update sources share staged PM validation.
Publish code and dependencies with recovery after process death.
Reject a concurrent enablement change before publishing disabled code.

Use the manifest loader's supported version in the installer. Keep
probe cooldowns for timeouts, not TLS failures that a CA change fixes.
Preserve the backup, uninstall, browser and memory-provider repairs.

Verified with the canonical runner on native Windows ARM64, real Git
repositories, local TLS endpoints and UV dependency generations.
Desktop catalog tests and both TypeScript checks pass. The full suite
and native release builds were not run. No remote push.
2026-09-09 16:49:27 -04:00

190 lines
9.5 KiB
Python

"""``hermes plugins`` subcommand parser."""
from __future__ import annotations
from typing import Callable
from hermes_cli.subcommands._shared import add_json_flag
def build_plugins_parser(subparsers, *, cmd_plugins: Callable) -> None:
"""Attach the ``plugins`` subcommand to ``subparsers``."""
plugins_parser = subparsers.add_parser(
"plugins", help="Manage and validate plugins",
description="Install, update, remove, list, or validate native Hermes plugins "
"and portable Agent Plugins v1 packages. Portable packages install disabled.")
plugins_subparsers = plugins_parser.add_subparsers(dest="plugins_action")
plugins_install = plugins_subparsers.add_parser(
"install", help="Install a plugin from the curated catalog, a Git URL, or owner/repo")
plugins_install.add_argument(
"identifier",
help="Bare plugin catalog entry name (see `hermes plugins search`), Git URL, or owner/repo "
"shorthand (e.g. anpicasso/hermes-plugin-chrome-profiles)")
plugins_install.add_argument(
"--force", "-f", action="store_true", help="Remove existing plugin and reinstall")
plugins_install.add_argument(
"--ref", metavar="COMMIT_SHA",
help="Install exactly one immutable 40-character Git commit SHA")
plugins_install.add_argument(
"--allow-removed", action="store_true",
help="DANGEROUS: bypass the catalog removed-plugin blocklist check")
_install_enable_group = plugins_install.add_mutually_exclusive_group()
_install_enable_group.add_argument(
"--enable", action="store_true",
help="Auto-enable the plugin after install (skip confirmation prompt)")
_install_enable_group.add_argument(
"--no-enable", action="store_true",
help="Install disabled (skip confirmation prompt); enable later with `hermes plugins enable <name>`",
)
plugins_search = plugins_subparsers.add_parser(
"search", help="Search the curated Hermes plugin catalog")
plugins_search.add_argument(
"term", nargs="?", default="",
help="Query matched against entry names, descriptions and declared tools (omit to list the whole catalog)")
add_json_flag(plugins_search, "Print machine-readable JSON")
plugins_subparsers.add_parser("browse", help="List every curated plugin catalog entry")
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")
add_json_flag(plugins_validate, "Print machine-readable JSON (for CI)")
plugins_update = plugins_subparsers.add_parser(
"update", help="Pull latest changes for an installed plugin")
plugins_update.add_argument("name", help="Plugin name to update")
plugins_adopt = plugins_subparsers.add_parser(
"adopt",
help="Adopt a self-cloned plugin dir into provenance tracking",
description=(
"For plugin dirs you cloned yourself (no install record): read "
"the git origin URL, write the provenance row, and become a "
"tracked git install (check-updates + update)."
),
)
plugins_adopt.add_argument("name", help="Self-cloned plugin directory name")
plugins_trust = plugins_subparsers.add_parser(
"trust-update-url",
help="Confirm a changed plugin update_url into the saved tag",
description=(
"The ONLY path that moves a saved update_url tag. When a "
"plugin's manifest changed its update_url (needs-fixing "
"mismatch), running this trusts the new url after review. "
"Never triggered automatically."
),
)
plugins_trust.add_argument("name", help="Plugin name")
plugins_check = plugins_subparsers.add_parser(
"check-updates",
aliases=["check"],
help="Check whether installed plugins have updates (read-only)",
description=(
"Standard, read-only update check for every installed plugin: "
"saved-tag update_url feeds (with mismatch protection), git "
"ls-remote for git installs, and a stateless PyPI probe for "
"pip entry-point plugins. NEVER mutates anything — apply with "
"`hermes plugins update <name>`."
),
)
plugins_check.add_argument(
"--json",
action="store_true",
help="Print machine-readable JSON (the receipt-section shape)",
)
plugins_remove = plugins_subparsers.add_parser(
"remove", aliases=["rm", "uninstall"], help="Remove an installed plugin")
plugins_remove.add_argument("name", help="Plugin directory name to remove")
plugins_list = plugins_subparsers.add_parser(
"list", aliases=["ls"], help="List installed plugins")
plugins_list.add_argument("--enabled", action="store_true", help="Show only enabled plugins")
plugins_list.add_argument(
"--user", action="store_true",
help="Show only user-installed plugins (including git plugins)")
plugins_list.add_argument("--no-bundled", action="store_true", help="Hide bundled plugins")
plugins_list.add_argument(
"--plain", action="store_true",
help="Print compact plain-text output instead of a Rich table")
add_json_flag(plugins_list, "Print machine-readable JSON")
plugins_enable = plugins_subparsers.add_parser("enable", help="Enable a disabled plugin")
plugins_enable.add_argument("name", help="Plugin name to enable")
_enable_override_group = plugins_enable.add_mutually_exclusive_group()
_enable_override_group.add_argument(
"--allow-tool-override", action="store_true",
help="Grant this plugin permission to replace built-in tools "
"(e.g. shell_exec, write_file). Skips the confirmation prompt.")
_enable_override_group.add_argument(
"--no-allow-tool-override", action="store_true",
help="Enable without granting built-in tool override (skip prompt).")
plugins_disable = plugins_subparsers.add_parser(
"disable", help="Disable a plugin without removing it")
plugins_disable.add_argument("name", help="Plugin name to disable")
plugins_capabilities = plugins_subparsers.add_parser(
"capabilities", help="Show declared vs granted capabilities per plugin",
description="Show each plugin's declared capabilities (from plugin.yaml) "
"against what the user has granted. Capabilities are a consent "
"and audit layer over host API surfaces — NOT a sandbox.")
plugins_capabilities.add_argument(
"name", nargs="?", default=None,
help="Plugin id to inspect (omit to list all plugins with capabilities)")
plugins_doctor = plugins_subparsers.add_parser(
"doctor", help="Validate a plugin with the real runtime contracts")
plugins_doctor.add_argument(
"target", nargs="?", default=".",
help="Plugin path or installed plugin id (default: current directory)")
plugins_doctor.add_argument(
"--ci", action="store_true", help="Exit non-zero when validation reports an error")
plugins_compat = plugins_subparsers.add_parser(
"compat",
help="Show installed plugins that import paths removed by the Sep 2026 decomposition",
description="Statically scans every enabled external plugin for imports of pre-decomposition "
"module paths (see COMPAT_MANIFEST.md) and prints file:line, old path -> new path. "
"Exits 1 when any plugin is affected. Plugins still affected on the removal date are "
"not loaded (override: plugins.allow_deprecated_imports: true).")
plugins_compat.add_argument("--json", action="store_true", help="Machine-readable output")
plugins_compat.add_argument(
"path", nargs="?", help="Scan one plugin directory instead of the installed set (for plugin authors)")
plugins_pack = plugins_subparsers.add_parser(
"pack", help="Declarative, shareable plugin sets (hermes-pack.yaml)",
description="Install, export, or inspect plugin packs — a single YAML file "
"pinning a set of plugins to exact commit SHAs, with optional "
"non-secret config seeds. Installing a pack fans out to ordinary "
"pinned installs; capability consent stays per-plugin.")
pack_subparsers = plugins_pack.add_subparsers(dest="pack_action")
pack_install = pack_subparsers.add_parser(
"install", help="Review and install a pack from a file path or https URL")
pack_install.add_argument("source", help="Path to a hermes-pack.yaml file, or an https:// URL")
pack_install.add_argument(
"--force", "-f", action="store_true", help="Reinstall plugins that already exist")
pack_export = pack_subparsers.add_parser(
"export", help="Emit a pack YAML for the current install on stdout")
pack_export.add_argument(
"--enabled-only", action="store_true",
help="Only include plugins currently in plugins.enabled")
pack_export.add_argument(
"--name", default="my-hermes-pack", help="Pack name to embed in the exported YAML")
pack_show = pack_subparsers.add_parser(
"show", help="Dry-run: parse and display a pack without installing")
pack_show.add_argument("source", help="Path to a hermes-pack.yaml file, or an https:// URL")
plugins_show = plugins_subparsers.add_parser(
"show", aliases=["info"], help="Show details for a single plugin (including emits/listens)")
plugins_show.add_argument("name", help="Plugin name or key to show")
plugins_parser.set_defaults(func=cmd_plugins)