Files
hermes-agent/hermes_cli/plugins_cmd_install.py
kshitijk4poor 062a6bfc57 refactor(plugins): one helper attributes scan blocks to preserved user files
64860c9c20 pasted the same try/except around the security scan in
_install_plugin_core and update_plugin, and plugins_transaction reached
into plugins_cmd_install for the private _preserved_files_note.

_scan_merged_tree now lives next to _scan_plugin_tree/PluginScanBlocked
in plugins_cmd and both sites call it once. Message, scan_result and the
chained cause are unchanged (checked for matched, unmatched, empty and
missing-scan_result cases). _preserved_files_note is typed
(PluginScanBlocked, list[str]) and drops the nested getattr/str() guards
for the module's usual `scan_result.findings if scan_result is not None`
form.
2026-09-27 01:42:52 +05:30

600 lines
28 KiB
Python

"""``hermes plugins install``: dependency/env consent, the atomic clone-scan-publish installer core, and
the dashboard/TUI non-interactive install.
Sibling of :mod:`hermes_cli.plugins_cmd` (the facade re-exports the names other modules use and is
imported late here, never at module level).
"""
from __future__ import annotations
import logging
import os
import sys
import tempfile
from pathlib import Path
from typing import Any, Optional
from hermes_cli.cli_output import line_input
logger = logging.getLogger(__name__)
def _pc():
"""The facade, read at call time: tests patch ``plugins_cmd.<name>`` and sibling calls must see it."""
from hermes_cli import plugins_cmd
return plugins_cmd
def _install_plugin_python_deps(
manifest: dict, target: Path, console
) -> tuple[bool, Optional[str]]:
"""Consent gate for plugin python deps (settled 2026-09-02; C13 rework).
Node sidecar: y/n prompt → ``npm ci`` into the plugin's OWN
node_modules (separate question, failure never blocks enable).
Python deps: NO resolution here — the resolve runs inside the ONE
admission transaction when the enable commits
(:func:`_admit_and_save_plugin_sets`), so the environment and the
config always change together or not at all. Returns (consented,
reason): consented=True when the user accepted (or no prompt was
needed); a decline/skip returns False and NOTHING is installed.
Never raises — the caller keeps the plugin installed-but-disabled.
"""
from pm.plugin_declarations import read_python_declaration
try:
declaration = read_python_declaration(target)
deps = declaration.install_requirements
except Exception as exc:
return False, f"invalid Python dependency declaration: {exc}"
has_python = declaration.is_member
has_package_json = (target / "package.json").is_file()
if not has_python and not has_package_json:
return True, None # no declared deps at all
# Node sidecar (package.json): the npm ci executor — separate consent
# question, same try-then-enable posture. Failure never blocks the
# python path below.
node_reason = None
if has_package_json:
console.print(f"\n[bold]{manifest.get('name', 'this plugin')}[/bold] declares Node dependencies (package.json).")
if sys.stdin.isatty() and sys.stdout.isatty():
try:
node_answer = input(
" Install them into the plugin's own node_modules now? [y/N]: "
).strip().lower()
except (EOFError, KeyboardInterrupt):
node_answer = ""
else:
node_answer = ""
if node_answer in {"y", "yes"}:
from pm.workspace import install_node_sidecar
node_reason = install_node_sidecar(target, explicit=True)
if node_reason:
console.print(f"[yellow]⚠[/yellow] Node deps: {node_reason}")
else:
console.print("[dim]Skipped Node deps — run `hermes plugins install` again to retry.[/dim]\n")
if not has_python:
return True, None
return _consent_python_deps(manifest.get("name", "this plugin"), deps, console)
def _consent_python_deps(plugin_name: str, deps: tuple[str, ...], console) -> tuple[bool, Optional[str]]:
"""The y/N gate for Python deps entering the shared environment — install,
reinstall AND an update that declares new ones all pass through here.
Returns (consented, reason); never raises."""
console.print(
f"\n[bold]{plugin_name}[/bold] declares Python dependencies:"
)
if deps:
for dep in deps:
console.print(f" - {dep}")
else:
console.print(" - (declared in its pyproject.toml)")
# A decline or non-interactive invocation leaves the new plugin disabled.
if not (sys.stdin.isatty() and sys.stdout.isatty()):
console.print(
"[dim]Non-interactive install — skipping dependency install. "
"Run `hermes plugins enable` when ready to prepare them.[/dim]\n"
)
return False, "dependency install skipped (non-interactive)"
try:
answer = input(
" Prepare these with Hermes through PM now? [y/N]: "
).strip().lower()
except (EOFError, KeyboardInterrupt):
answer = ""
if answer not in {"y", "yes"}:
console.print(
"[dim]Skipped — run `hermes plugins enable` when ready "
"to prepare them.[/dim]\n"
)
return False, "dependency install declined"
# Consent only — the python-deps resolution itself runs inside the ONE
# admission transaction at enable-commit time (C13): env + config move
# together or not at all.
return True, None
def _python_dependency_summary(target: Path, warnings: list[str]) -> list[str]:
"""Read dashboard dependency details; publication and admission own installation."""
from pm.plugin_declarations import read_python_declaration
try:
return list(read_python_declaration(target).install_requirements)
except Exception as exc:
warnings.append(f"Could not read Python dependencies: {exc}")
return []
def _prompt_plugin_env_vars(manifest: dict, console) -> None:
"""Prompt for unset ``requires_env`` variables and save the answers to the user's ``.env``."""
missing = _pc()._missing_env_specs(manifest)
if not missing:
return
from hermes_cli.config import save_env_value
from hermes_constants import display_hermes_home
plugin_name = manifest.get("name", "this plugin")
console.print(f"\n[bold]{plugin_name}[/bold] requires the following environment variables:\n")
for spec in missing:
name = spec["name"]
desc = spec.get("description", "")
url = spec.get("url", "")
console.print(f" {name}" + (f" — {desc}" if desc else ""))
if url:
console.print(f" [dim]Get yours at: {url}[/dim]")
try:
value = (_pc().masked_secret_prompt if spec.get("secret", False) else line_input)(f" {name}: ").strip()
except (EOFError, KeyboardInterrupt):
console.print(f"\n[dim] Skipped (you can set these later in {display_hermes_home()}/.env)[/dim]")
return
if value:
save_env_value(name, value)
os.environ[name] = value
console.print(f" [green]✓[/green] Saved to {display_hermes_home()}/.env")
else:
console.print(f" [dim] Skipped (set {name} in {display_hermes_home()}/.env later)[/dim]")
console.print()
def _display_after_install(plugin_dir: Path, identifier: str) -> None:
"""Show after-install.md if it exists, otherwise a default message."""
from rich.markdown import Markdown
from rich.panel import Panel
console = _pc()._console()
after_install = plugin_dir / "after-install.md"
if after_install.exists():
body, title = Markdown(after_install.read_text(encoding="utf-8-sig")), None
else:
body = f"[green bold]Plugin installed:[/] {identifier}\n[dim]Location:[/] {plugin_dir}"
title = "✓ Installed"
console.print()
console.print(Panel(body, border_style="green", title=title, expand=False))
console.print()
def _check_manifest_version(manifest: dict, plugin_name: str) -> None:
"""Reject manifests declaring a newer ``manifest_version`` than this installer supports."""
from pm.plugin_declarations import manifest_version_error
reason = manifest_version_error(manifest, plugin_name)
if reason:
from hermes_cli.config import recommended_update_command
raise _pc().PluginOperationError(f"{reason} Run {recommended_update_command()} to update Hermes.")
def _read_manifest_for_install(plugin_dir: Path) -> dict:
"""A candidate's unreadable or malformed manifest must stop publication."""
native = _pc()._native_manifest_file(plugin_dir)
if native is not None:
try:
manifest = _pc()._load_yaml_manifest(native)
except Exception as exc:
raise _pc().PluginOperationError(f"Could not read plugin manifest {native}: {exc}") from exc
if not isinstance(manifest, dict):
raise _pc().PluginOperationError(f"Plugin manifest must be a mapping: {native}")
return manifest
if not _pc()._has_portable_manifest(plugin_dir):
return {}
try:
from hermes_cli.agent_plugins import read_agent_plugin_manifest
manifest, diagnostics = read_agent_plugin_manifest(plugin_dir)
except Exception as exc:
raise _pc().PluginOperationError(f"Portable plugin manifest validation failed: {exc}") from exc
for diagnostic in diagnostics:
logger.warning("Agent Plugin install: %s", diagnostic.message)
return manifest
def _probe_readable(path: Path) -> None:
"""Raise ``OSError`` unless *path* can actually be listed (dir) or opened for reading (file)."""
if path.is_dir():
os.listdir(path)
else:
with open(path, "rb"):
pass
def _ensure_tree_readable(root: Path, plugins_dir: Path) -> None:
"""Refuse to ship a tree Hermes cannot read back. A clone can land unreadable (Windows ACL
inheritance -> WinError 5, a mode-000 file) and discovery would then skip the plugin forever
(#111804); repair ``u+rX`` where the OS supports it, otherwise fail before anything moves."""
paths = [root]
for dirpath, dirnames, filenames in os.walk(root):
paths.extend(Path(dirpath) / name for name in (*dirnames, *filenames))
for path in paths:
try:
_probe_readable(path)
continue
except OSError:
if os.name != "nt": # chmod only toggles the read-only bit on Windows; ACLs need icacls
try:
os.chmod(path, os.stat(path).st_mode | (0o500 if path.is_dir() else 0o400))
except OSError:
pass
try:
_probe_readable(path)
except OSError as exc:
fix = (f'icacls "{plugins_dir}" /grant:r "%USERNAME%":(OI)(CI)F /T' if os.name == "nt"
else f"chmod -R u+rX {plugins_dir}")
raise _pc().PluginOperationError(
f"Installed file {path.relative_to(root)} is not readable ({exc.strerror or exc}); "
f"nothing was installed. Fix permissions on {plugins_dir} (e.g. `{fix}`) and retry."
) from exc
def _refuse_unavailable_portable_plugin(plugin_name: str, tree: Path) -> None:
if not (tree / "plugin.json").is_file():
return
from hermes_cli.agent_plugins import load_agent_plugin
from hermes_platform.resolver.availability import availability
try:
package = load_agent_plugin(tree, tree.parent / ".hermes-install-data")
except ValueError as exc:
raise _pc().PluginOperationError(f"Plugin '{plugin_name}' is unavailable: {exc}.") from exc
for server_name, server_decl in package.server_declarations.items():
result = availability(server_decl.declaration)
if result.offerable:
continue
found = f", found version {result.version}" if result.version else ""
raise _pc().PluginOperationError(
f"Plugin '{plugin_name}' server '{server_name}' is unavailable: {result.state}{found}."
)
def _install_plugin_core(
identifier: str,
*,
force: bool,
ref: Optional[str] = None,
scan_decision_cb=None,
reviewed_pin: Optional[str] = None,
python_deps: bool = True,
catalog: Optional[dict] = None,
allow_removed: bool = False,
before_swap=None,
) -> tuple[Path, dict, str]:
"""Clone a Git plugin and atomically record its source and exact revision.
*reviewed_pin* is the curated-catalog sha for this install; the scan trusts the tree
only when the checked-out revision is exactly that sha (an annotated-tag pin is peeled to
its commit first — HEAD can only ever be the commit). *python_deps* False refuses active
replacements; it never bypasses PM dependency admission. *catalog*
(``{"name", "repo", "tier", "pin"}``) is recorded on the install-metadata record with the
checked-out sha — provenance lives OUTSIDE the plugin tree, so a repo cannot forge it;
its ``pin`` is kept only when the checkout satisfies it (a ``--ref`` install is off-pin).
*allow_removed* records that the user knowingly bypassed the kill list.
*before_swap(manifest, tree)* runs on the manifest-checked clone BEFORE the security scan, so
the single scan and portable-package check admit the merged tree (file-count/size limits
included). It may return the relative paths it merged in, which a scan block then attributes,
and may raise :class:`PluginOperationError` to abort (re-pin consent)."""
requested_revision = _pc()._normalize_exact_revision(ref) if ref is not None else None
try:
git_url, subdir = _pc()._resolve_git_url(identifier)
except ValueError as e:
raise _pc().PluginOperationError(str(e)) from e
plugins_dir = _pc()._plugins_dir()
source = _pc()._canonical_source(git_url, subdir)
old_metadata = _pc()._read_install_metadata()
# Reinstalling the same pinned source retains its pin, even if its plugin
# directory was manually removed. Moving a pin requires an explicit --ref.
if requested_revision is None:
pins = [e for e in old_metadata.values() if e.get("source") == source and e.get("pinned") is True]
if len(pins) == 1 and isinstance(pins[0].get("revision"), str):
requested_revision = _pc()._normalize_exact_revision(pins[0]["revision"])
with tempfile.TemporaryDirectory(prefix=".install-", dir=plugins_dir) as tmp:
tmp_clone = Path(tmp) / "plugin"
installed_revision = _pc()._clone_plugin_repo(tmp_clone, git_url, requested_revision, subdir)
git_exe = _pc()._resolve_git_executable()
at_reviewed_pin = bool(reviewed_pin) and installed_revision == (
_pc()._git_resolve_commit(tmp_clone, git_exe, reviewed_pin) if git_exe and reviewed_pin else reviewed_pin)
tmp_target = _pc()._resolve_subdir_within(tmp_clone, subdir) if subdir else tmp_clone
_ensure_tree_readable(tmp_target, plugins_dir)
manifest = _read_manifest_for_install(tmp_target)
plugin_name = manifest.get("name") or (
subdir.rstrip("/").rsplit("/", 1)[-1] if subdir else _pc()._repo_name_from_url(git_url))
try:
target = _pc()._sanitize_plugin_name(plugin_name, plugins_dir)
except ValueError as e:
raise _pc().PluginOperationError(str(e)) from e
_check_manifest_version(manifest, plugin_name)
# A callback may merge user-owned state into the candidate tree; run it first so one scan
# admits the final bytes.
merged = before_swap(manifest, tmp_target) if before_swap is not None else None
# Scan BEFORE anything is moved into place; raises PluginScanBlocked when blocked.
_pc()._scan_merged_tree(tmp_target, identifier, merged, force=force, scan_decision_cb=scan_decision_cb,
reviewed_pin=at_reviewed_pin)
if not python_deps:
from pm.workspace import enabled_plugin_dirs
if target.resolve() in enabled_plugin_dirs(installing=target):
raise _pc().PluginOperationError(
"--no-deps cannot replace an active plugin. Retry without --no-deps; "
"PM must prepare its dependencies before publication.")
_refuse_unavailable_portable_plugin(plugin_name, tmp_target)
if target.exists() and not force:
raise _pc().PluginOperationError(
f"Plugin '{plugin_name}' already exists. Use force reinstall "
f"or run `hermes plugins update {plugin_name}`.")
prior = old_metadata.get(plugin_name)
if target.exists() and requested_revision is None and isinstance(prior, dict) and prior.get("pinned") is True:
raise _pc().PluginOperationError(
f"Plugin '{plugin_name}' is pinned. Reinstall it with an explicit "
"--ref <40-character commit SHA> to change its source or revision.")
record: dict[str, object] = {
"pinned": requested_revision is not None,
"revision": installed_revision,
"source": source,
}
# Saved update_url tag (settled: claims vs provenance): the
# manifest's update_url is COPIED into the row at install. Check
# time compares manifest vs tag; a mismatch is needs-fixing and
# only `hermes plugins trust-update-url` moves the tag.
if manifest.get("update_url"):
from hermes_cli.plugins_updates import https_update_url
try:
record["update_url"] = https_update_url(manifest["update_url"])
except ValueError as exc:
raise _pc().PluginOperationError(f"Plugin '{plugin_name}' {exc}") from exc
if catalog:
# ``sha`` = the commit checked out; ``pin`` = the reviewed catalog sha it satisfies (the
# annotated-tag object for a tag pin), empty when installed off-pin via ``--ref``.
record["catalog"] = {
**catalog,
"sha": installed_revision,
"pin": reviewed_pin if at_reviewed_pin else "",
}
from hermes_cli.plugins_cmd_catalog import write_catalog_sidecar_record
write_catalog_sidecar_record(tmp_target, catalog, installed_revision)
if allow_removed:
record["allow_removed"] = True
new_metadata = {**old_metadata, plugin_name: record}
from hermes_cli.plugins_transaction import publish_plugin
try:
publish_plugin(tmp_target, target, old_metadata, new_metadata, require_consent=True)
except Exception as exc:
raise _pc().PluginOperationError(f"Plugin '{plugin_name}' was not published: {exc}") from exc
if not _pc()._looks_like_plugin_dir(target):
logger.warning("%s has no plugin.yaml / __init__.py; may not be a valid plugin", plugin_name)
_pc()._copy_example_files(target, _pc()._console())
installed_manifest = _pc()._read_manifest(target)
return target, installed_manifest, installed_manifest.get("name") or target.name
def cmd_install(
identifier: str,
force: bool = False,
enable: Optional[bool] = None,
ref: Optional[str] = None,
allow_removed: bool = False,
no_deps: bool = False,
) -> None:
"""Install a plugin from the curated catalog (bare name), a Git URL, or owner/repo shorthand.
A catalog hit installs the reviewed pinned SHA and records catalog membership in the shared install
metadata. An explicit different ``--ref`` is a custom pin. URLs/shorthand are custom sources. Every
install is checked against the catalog kill list unless *allow_removed*.
*enable* None prompts "Enable now? [y/N]"; True/False skip the prompt.
"""
from hermes_cli import plugins_cmd_catalog as catalog
console = _pc()._console()
entry = None
if catalog.looks_like_catalog_name(identifier):
entry = catalog.resolve_catalog_name(identifier, console)
identifier = entry.install_identifier
console.print(f"[bold]{entry.name}[/bold] [cyan]\\[{entry.tier}][/cyan] [dim]pinned @ {entry.sha[:8]}[/dim]")
console.print(catalog.entry_capability_summary(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 catalog kill-list check. "
"This plugin may have been removed for security reasons.[/red]")
try:
git_url, _subdir = _pc()._resolve_git_url(identifier)
if not allow_removed:
catalog.raise_if_removed(identifier, git_url, *((entry.name,) if entry else ()))
except (ValueError, _pc().PluginOperationError) as e:
_pc()._fail(console, f"[red]Error:[/red] {e}")
if git_url.startswith(("http://", "file://")):
console.print(
"[yellow]Warning:[/yellow] Using insecure/local URL scheme. "
"Consider using https:// or git@ for production installs.")
console.print(f"[dim]Cloning {git_url}{f' (subdir: {_subdir})' if _subdir else ''}...[/dim]")
def _interactive_scan_decision(scan_result) -> bool:
"""Prompt the user to accept a caution-verdict plugin."""
from tools.plugin_guard import format_scan_report
console.print()
console.print("[yellow]⚠ Security scan flagged this plugin:[/yellow]")
console.print(format_scan_report(scan_result))
return _pc()._is_tty() and _pc()._ask_yes(" Install anyway? Only continue if you trust the source. [y/N]: ")
try:
if entry is not None:
target, installed_manifest, installed_name = catalog.install_catalog_entry(
entry, force=force, ref=ref, allow_removed=allow_removed, scan_decision_cb=_interactive_scan_decision,
python_deps=not no_deps)
else:
target, installed_manifest, installed_name = _pc()._install_plugin_core(
identifier, force=force, ref=ref, scan_decision_cb=_interactive_scan_decision,
python_deps=not no_deps, allow_removed=allow_removed)
except _pc().PluginOperationError as e:
_pc()._fail(console, f"[red]{'Blocked' if isinstance(e, _pc().PluginScanBlocked) else 'Error'}:[/red] {e}")
if not _pc()._looks_like_plugin_dir(target):
console.print(
f"[yellow]Warning:[/yellow] {installed_name} doesn't contain plugin.yaml, "
f"plugin.json, or __init__.py. It may not be a valid Hermes plugin.")
_prompt_plugin_env_vars(installed_manifest, console)
from pm.workspace import enabled_plugin_dirs
# Active replacements settled consent against the staged tree before PM
# prepared or published it. Do not present a second, ineffective veto.
already_active = target.resolve() in enabled_plugin_dirs()
should_enable = False if no_deps else enable
if no_deps:
console.print("[dim]--no-deps: skipping dependency consent; the plugin stays disabled.[/dim]")
if should_enable is None and not already_active:
should_enable = _pc()._is_tty() and _pc()._ask_yes(f" Enable '{installed_name}' now? [y/N]: ")
deps_ok, deps_reason = (True, None)
if should_enable and not already_active:
deps_ok, deps_reason = _install_plugin_python_deps(installed_manifest, target, console)
_pc()._display_after_install(target, identifier)
# ONE admission transaction for the enable (C13): resolve the candidate
# union (enabled members + this target) and commit the config in the
# same step — env and config change together or not at all. No
# duplicate sync here: nothing was resolved before this point.
if should_enable and not deps_ok:
# Consent declined/skipped: nothing was installed or changed, so
# enabling is refused without touching config or environment.
console.print(
f"[red]✗[/red] Cannot enable [bold]{installed_name}[/bold]: "
f"{deps_reason}"
)
console.print(
"[dim]The plugin stays installed but disabled; re-enable "
"after resolving the conflict.[/dim]"
)
should_enable = False
if already_active:
console.print("[dim]Replacement installed; plugin selection was not changed.[/dim]")
elif should_enable:
from hermes_cli.plugins_admission import AdmissionRefused
try:
_pc()._set_plugin_enabled(installed_name, enable=True, console=console)
except AdmissionRefused:
console.print(
"[dim]The plugin stays installed but disabled; re-enable "
"after resolving the conflict.[/dim]"
)
else:
console.print(
f"[green]✓[/green] Plugin [bold]{installed_name}[/bold] enabled.",
)
else:
console.print(
f"[dim]Plugin installed but not enabled. "
f"Run `hermes plugins enable {installed_name}` to activate.[/dim]")
# Non-interactive installs and declines leave declared capabilities ungranted (fail closed).
declared_caps = _pc()._declared_capabilities_from_manifest(installed_manifest, installed_name)
if declared_caps:
_pc()._run_capability_consent(console, installed_name, declared_caps, context="install")
if enable:
# Loads it into the running gateway now (handlers live) or says what needs a restart (#87770).
from hermes_cli.plugins_activation import activate_plugin_now, activation_hint
console.print(f"[dim]{activation_hint(activate_plugin_now(installed_name, in_process=False))}[/dim]")
console.print()
def dashboard_install_plugin(
identifier: str, *, force: bool, enable: bool, catalog_name: Optional[str] = None,
ref: Optional[str] = None,
) -> dict[str, Any]:
"""Non-interactive install for the dashboard/TUI. *catalog_name* installs a curated entry at its
pinned SHA (identifier may be empty); *ref* pins a custom source to one full commit SHA (same
contract as ``--ref``); every path enforces the kill list (no GUI bypass)."""
from hermes_cli import plugins_cmd_catalog as catalog
warnings: list[str] = []
entry = None
if catalog_name:
entry = catalog.get_live_catalog_entry(catalog_name)
if entry is None:
return {"ok": False, "error": f"'{catalog_name}' is not in the Hermes plugin catalog."}
identifier = entry.install_identifier
else:
warnings.append("Custom (unreviewed) source — not from the Hermes catalog.")
try:
git_url = _pc()._resolve_git_url(identifier)[0]
if git_url.startswith(("http://", "file://")):
warnings.append("Insecure URL scheme; prefer https:// or git@ for production installs.")
catalog.raise_if_removed(identifier, git_url, *((entry.name,) if entry else ()))
except ValueError:
pass
except _pc().PluginOperationError as exc:
return {"ok": False, "error": str(exc)}
try:
if entry is not None:
target, installed_manifest, installed_name = catalog.install_catalog_entry(
entry, force=force, allow_removed=False)
else:
target, installed_manifest, installed_name = _pc()._install_plugin_core(
identifier, force=force, ref=(ref or "").strip() or None)
except _pc().PluginScanBlocked as exc:
fields = ("pattern_id", "severity", "category", "file", "line", "description")
return {
"ok": False, "error": str(exc), "scan_blocked": True,
"scan_verdict": getattr(exc.scan_result, "verdict", "dangerous"),
"scan_findings": [
{k: getattr(f, k) for k in fields}
for f in (exc.scan_result.findings if exc.scan_result is not None else ())
],
}
except _pc().PluginOperationError as exc:
return {"ok": False, "error": str(exc)}
if enable:
from hermes_cli.plugins_admission import AdmissionRefused
try:
_pc()._set_plugin_enabled(installed_name, enable=True)
except AdmissionRefused as exc:
return {
"ok": False, "error": f"enable refused: {exc}",
"plugin_name": installed_name, "enabled": False,
}
deps = _pc()._python_dependency_summary(target, warnings)
ap = target / "after-install.md"
# Deps first, then load: the plugin activates in this process (TUI/Desktop server subscribers see it)
# and in the running gateway; ``activation`` says what is live now vs next session (#87770).
from hermes_cli.plugins_activation import activate_plugin_now
activated = activate_plugin_now(installed_name) if enable else {
"gateway_reloaded": False, "activation": None, "restart_required": False}
return {
"ok": True, "plugin_name": installed_name, "warnings": warnings,
"python_dependencies": deps,
"missing_env": [s["name"] for s in _pc()._missing_env_specs(installed_manifest)],
"after_install_path": str(ap) if ap.exists() else None, "enabled": enable, **activated,
}