feat(plugins): install declared Python dependencies and re-apply them after hermes update
A directory plugin's pyproject.toml [project].dependencies (or manifest python_dependencies) are now installed into the Hermes venv on install/enable/ update, resolved under a constraints file built from Hermes' own pinned dependencies together with every enabled peer's declarations. A candidate that cannot resolve is refused before its tree is moved into place; nothing is installed and no other plugin is touched. --no-deps opts a single install out. hermes update rebuilds the venv from Hermes' lock and strips everything else, so its git, zip and both repair paths now re-apply the union of every profile's enabled plugins. When the union no longer resolves, each plugin is resolved on its own so only culprits are dropped (never an alphabetical neighbour); a plugin-vs-plugin conflict peels non-memory plugins first because a Hermes that boots without memory reads as data loss. Dropped plugins are disabled through the real config writer with a loud message naming the fix. python_runtime: external lets sidecar-venv plugins (Mnemosyne's shape) opt out of the union; hermes-agent self-dependencies and direct-URL requirements are never installed (the latter are surfaced for the user to install by hand).
This commit is contained in:
@@ -317,6 +317,7 @@ def install_pack_plugins(
|
||||
_get_disabled_set,
|
||||
_get_enabled_set,
|
||||
_install_plugin_core,
|
||||
_install_python_dependencies,
|
||||
_prompt_plugin_env_vars,
|
||||
_run_capability_consent,
|
||||
_save_disabled_set,
|
||||
@@ -350,6 +351,7 @@ def install_pack_plugins(
|
||||
_prompt_plugin_env_vars(manifest, console)
|
||||
except Exception:
|
||||
logger.debug("requires_env prompt failed for %s", installed_name, exc_info=True)
|
||||
_install_python_dependencies(target, console)
|
||||
|
||||
enabled = _get_enabled_set()
|
||||
disabled = _get_disabled_set()
|
||||
|
||||
440
hermes_cli/plugin_python_deps.py
Normal file
440
hermes_cli/plugin_python_deps.py
Normal file
@@ -0,0 +1,440 @@
|
||||
"""Python dependencies declared by user plugins: read, resolve against Hermes' own ranges, install,
|
||||
and re-apply after ``hermes update`` rebuilds the venv.
|
||||
|
||||
A plugin declares deps in its ``pyproject.toml`` (``[project].dependencies``) or, without one, in
|
||||
``plugin.yaml`` ``python_dependencies`` (``pip_dependencies`` is an accepted alias). ``python_runtime:
|
||||
external`` in the manifest opts a plugin out: it manages its own interpreter (sidecar venv) and its tree
|
||||
is never handed to the resolver.
|
||||
|
||||
Contract (agreed with the Mnemosyne team, Sep 2026): the union of every enabled plugin's declarations
|
||||
is resolved together with Hermes' declared ranges; a candidate that has no solution is refused without
|
||||
touching the live venv or disabling anything already installed. After an update, the union is
|
||||
re-applied; if core moved and the union no longer resolves, non-memory plugins are dropped first and
|
||||
disabled with a loud warning, because a Hermes that boots without memory reads as data loss.
|
||||
|
||||
Lifted in shape from ethernet8023's ``pm/plugin_declarations.py`` / ``pm/workspace.py`` (#102765).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import tomllib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable, Optional
|
||||
|
||||
import yaml
|
||||
from hermes_constants import get_hermes_home
|
||||
from packaging.markers import InvalidMarker, UndefinedEnvironmentName
|
||||
from packaging.requirements import InvalidRequirement, Requirement
|
||||
from packaging.utils import canonicalize_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MANIFEST_FILES = ("plugin.yaml", "plugin.yml")
|
||||
_DEP_KEYS = ("python_dependencies", "pip_dependencies")
|
||||
EXTERNAL_RUNTIME_KEY = "python_runtime"
|
||||
_CORE_DIST = "hermes-agent"
|
||||
# Substrings uv/pip print only when the resolver itself proved there is no solution. Deliberately
|
||||
# narrow: a fetch timeout or index outage must never be misread as a conflict.
|
||||
_CONFLICT_MARKERS = (
|
||||
"no solution found",
|
||||
"conflicting requirements",
|
||||
"resolutionimpossible",
|
||||
"because only the following versions",
|
||||
)
|
||||
|
||||
|
||||
class DependencyConflict(Exception):
|
||||
"""The resolver proved core + enabled plugins + candidate has no valid solution."""
|
||||
|
||||
|
||||
class DependencyInstallError(Exception):
|
||||
"""The install failed for a reason other than a proven conflict (network, build, gate)."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PythonDeclaration:
|
||||
plugin_dir: Path
|
||||
name: str
|
||||
specs: tuple[str, ...]
|
||||
source: str # "pyproject" | "manifest" | ""
|
||||
external: bool = False
|
||||
memory_provider: bool = False
|
||||
|
||||
@property
|
||||
def wants_install(self) -> bool:
|
||||
return bool(self.specs) and not self.external
|
||||
|
||||
|
||||
# ── reading ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _read_manifest(plugin_dir: Path) -> dict:
|
||||
for candidate in _MANIFEST_FILES:
|
||||
path = plugin_dir / candidate
|
||||
if path.is_file():
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8-sig")) or {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
return {}
|
||||
|
||||
|
||||
def _pyproject_specs(plugin_dir: Path) -> Optional[list[str]]:
|
||||
"""``[project].dependencies`` from the plugin's pyproject, or ``None`` when there is none."""
|
||||
path = plugin_dir / "pyproject.toml"
|
||||
if not path.is_file():
|
||||
return None
|
||||
document = tomllib.loads(path.read_text(encoding="utf-8-sig"))
|
||||
specs = document.get("project", {}).get("dependencies", [])
|
||||
if not isinstance(specs, list) or any(not isinstance(s, str) for s in specs):
|
||||
raise ValueError(f"{path}: [project].dependencies must be a list of strings")
|
||||
return specs
|
||||
|
||||
|
||||
def _manifest_specs(manifest: dict) -> list[str]:
|
||||
specs: list[str] = []
|
||||
for key in _DEP_KEYS:
|
||||
values = manifest.get(key) or []
|
||||
if not isinstance(values, list) or any(not isinstance(v, str) for v in values):
|
||||
raise ValueError(f"{key} must be a list of requirement strings")
|
||||
specs.extend(v.strip() for v in values if v.strip())
|
||||
return list(dict.fromkeys(specs))
|
||||
|
||||
|
||||
def _is_memory_provider(plugin_dir: Path, manifest: dict) -> bool:
|
||||
if str(manifest.get("kind") or "").strip().lower() == "exclusive":
|
||||
return True
|
||||
init = plugin_dir / "__init__.py"
|
||||
if not init.is_file():
|
||||
return False
|
||||
head = init.read_text(encoding="utf-8", errors="replace")[:8192]
|
||||
return "register_memory_provider" in head or "MemoryProvider" in head
|
||||
|
||||
|
||||
def read_declaration(plugin_dir: Path) -> PythonDeclaration:
|
||||
"""One effective dependency surface for a plugin directory. A real pyproject owns packaging; the
|
||||
manifest list bridges plugins without one. Raises ``ValueError`` on a malformed declaration."""
|
||||
plugin_dir = Path(plugin_dir)
|
||||
manifest = _read_manifest(plugin_dir)
|
||||
name = str(manifest.get("name") or plugin_dir.name)
|
||||
external = str(manifest.get(EXTERNAL_RUNTIME_KEY) or "").strip().lower() == "external"
|
||||
pyproject = _pyproject_specs(plugin_dir)
|
||||
specs, source = (pyproject, "pyproject") if pyproject is not None else (_manifest_specs(manifest), "manifest")
|
||||
return PythonDeclaration(
|
||||
plugin_dir=plugin_dir, name=name, specs=tuple(specs), source=source if specs else "",
|
||||
external=external, memory_provider=_is_memory_provider(plugin_dir, manifest))
|
||||
|
||||
|
||||
# ── requirement hygiene ───────────────────────────────────────────────────────
|
||||
|
||||
def _parse_requirement(spec: str) -> Requirement:
|
||||
try:
|
||||
return Requirement(spec)
|
||||
except InvalidRequirement as exc:
|
||||
raise ValueError(f"invalid requirement {spec!r}: {exc}") from exc
|
||||
|
||||
|
||||
def _marker_applies(req: Requirement) -> bool:
|
||||
try:
|
||||
return req.marker is None or req.marker.evaluate()
|
||||
except (InvalidMarker, UndefinedEnvironmentName) as exc:
|
||||
raise ValueError(f"invalid marker in {req!s}: {exc}") from exc
|
||||
|
||||
|
||||
def applicable_requirement(spec: str) -> Optional[str]:
|
||||
"""``spec`` reduced to ``name[extras]specifier`` when Hermes should install it here; ``None`` when
|
||||
its environment marker excludes this platform, when it names Hermes itself (always satisfied by the
|
||||
running checkout — installing ``hermes-agent`` from an index would clobber it), or when it points
|
||||
at a URL/path (see :func:`unsupported_specs`; the resolver must only see index packages)."""
|
||||
req = _parse_requirement(spec)
|
||||
if req.url or canonicalize_name(req.name) == _CORE_DIST or not _marker_applies(req):
|
||||
return None
|
||||
extras = f"[{','.join(sorted(req.extras))}]" if req.extras else ""
|
||||
return f"{req.name}{extras}{req.specifier}"
|
||||
|
||||
|
||||
def applicable_specs(specs: Iterable[str]) -> list[str]:
|
||||
return [r for r in (applicable_requirement(s) for s in specs) if r]
|
||||
|
||||
|
||||
def unsupported_specs(specs: Iterable[str]) -> list[str]:
|
||||
"""Direct URL/path requirements: never installed by Hermes (arbitrary git/tarball sources are a
|
||||
supply-chain surface the reviewed pin does not cover); surfaced so the user can install them."""
|
||||
return [s for s in specs if _parse_requirement(s).url]
|
||||
|
||||
|
||||
def core_constraints(project_root: Path) -> list[str]:
|
||||
"""Hermes' own declared ranges (``[project].dependencies`` + every extra), as constraint lines.
|
||||
Plugins resolve inside these, so they can move transitives but never a core package out of range."""
|
||||
document = tomllib.loads((Path(project_root) / "pyproject.toml").read_text(encoding="utf-8"))
|
||||
project = document.get("project", {})
|
||||
declared: list[str] = list(project.get("dependencies", []))
|
||||
for group in project.get("optional-dependencies", {}).values():
|
||||
declared.extend(group)
|
||||
lines = []
|
||||
for spec in declared:
|
||||
try:
|
||||
reduced = applicable_requirement(spec)
|
||||
except ValueError:
|
||||
continue
|
||||
if reduced and not reduced.lower().startswith(_CORE_DIST):
|
||||
lines.append(_strip_extras(reduced))
|
||||
return sorted(set(lines))
|
||||
|
||||
|
||||
def _strip_extras(spec: str) -> str:
|
||||
"""Constraint files may not carry extras."""
|
||||
return re.sub(r"\[[^\]]*\]", "", spec, count=1)
|
||||
|
||||
|
||||
# ── enumeration across homes ──────────────────────────────────────────────────
|
||||
|
||||
def _name_set(config: dict, key: str) -> set[str]:
|
||||
values = (config.get("plugins") or {}).get(key) if isinstance(config.get("plugins"), dict) else None
|
||||
return set(values) if isinstance(values, list) else set()
|
||||
|
||||
|
||||
def _plugin_enabled(decl: PythonDeclaration, enabled: set[str], disabled: set[str]) -> bool:
|
||||
keys = {decl.name, decl.plugin_dir.name}
|
||||
return bool(keys & enabled) and not (keys & disabled)
|
||||
|
||||
|
||||
def _read_home_config(home: Path) -> dict:
|
||||
path = home / "config.yaml"
|
||||
if not path.is_file():
|
||||
return {}
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def enabled_declarations(home: Path) -> list[PythonDeclaration]:
|
||||
"""Declarations of every enabled, non-external user plugin under ``<home>/plugins`` that declares
|
||||
something. Malformed declarations are skipped with a warning (one bad plugin must not stop the
|
||||
union for everyone else)."""
|
||||
plugins_dir = Path(home) / "plugins"
|
||||
if not plugins_dir.is_dir():
|
||||
return []
|
||||
config = _read_home_config(Path(home))
|
||||
enabled, disabled = _name_set(config, "enabled"), _name_set(config, "disabled")
|
||||
found: list[PythonDeclaration] = []
|
||||
for plugin_dir in sorted(p for p in plugins_dir.iterdir() if p.is_dir() and not p.name.startswith(".")):
|
||||
try:
|
||||
decl = read_declaration(plugin_dir)
|
||||
except (ValueError, OSError, tomllib.TOMLDecodeError, yaml.YAMLError) as exc:
|
||||
logger.warning("Plugin %s: dependency declaration skipped: %s", plugin_dir.name, exc)
|
||||
continue
|
||||
if decl.wants_install and _plugin_enabled(decl, enabled, disabled):
|
||||
found.append(decl)
|
||||
return found
|
||||
|
||||
|
||||
def dependency_homes() -> list[Path]:
|
||||
"""Every home whose plugins share this venv: the default home plus live named profiles."""
|
||||
from hermes_cli.profiles import profiles_to_serve
|
||||
return [home for _name, home in profiles_to_serve(multiplex=True)]
|
||||
|
||||
|
||||
def union_specs(declarations: Iterable[PythonDeclaration]) -> list[str]:
|
||||
seen: dict[str, None] = {}
|
||||
for decl in declarations:
|
||||
for spec in applicable_specs(decl.specs):
|
||||
seen.setdefault(spec, None)
|
||||
return list(seen)
|
||||
|
||||
|
||||
# ── resolve / install ─────────────────────────────────────────────────────────
|
||||
|
||||
def _classify(result) -> Optional[Exception]:
|
||||
if result.ok:
|
||||
return None
|
||||
text = (result.stderr + result.stdout).lower()
|
||||
if result.blocked:
|
||||
return DependencyInstallError(result.reason)
|
||||
if any(marker in text for marker in _CONFLICT_MARKERS):
|
||||
return DependencyConflict(_tail(result.stderr or result.stdout))
|
||||
return DependencyInstallError(_tail(result.stderr or result.stdout) or "installer failed")
|
||||
|
||||
|
||||
def _tail(text: str, limit: int = 600) -> str:
|
||||
return text.strip()[-limit:]
|
||||
|
||||
|
||||
def resolve(specs: list[str], constraints: list[str], *, dry_run: bool, timeout: int = 600):
|
||||
"""Run the shared installer ladder on *specs* under *constraints*. Raises ``DependencyConflict`` or
|
||||
``DependencyInstallError``; returns the installer result on success."""
|
||||
from tools.lazy_deps import install_specs
|
||||
result = install_specs(specs, timeout=timeout, constraints=constraints, dry_run=dry_run)
|
||||
error = _classify(result)
|
||||
if error is not None:
|
||||
raise error
|
||||
return result
|
||||
|
||||
|
||||
def check_candidate(candidate: PythonDeclaration, *, home: Path, project_root: Path) -> list[str]:
|
||||
"""Prove core + every enabled plugin + *candidate* resolves, without installing. Returns the
|
||||
candidate's applicable specs (empty when it declares nothing for this platform)."""
|
||||
own = applicable_specs(candidate.specs)
|
||||
if not own or candidate.external:
|
||||
return []
|
||||
resolve(union_specs([candidate, *_peers(candidate, home)]), core_constraints(project_root), dry_run=True)
|
||||
return own
|
||||
|
||||
|
||||
def _satisfied(spec: str) -> bool:
|
||||
"""Installed and inside the specifier (extras are not checked; a missing extra surfaces at import)."""
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
req = Requirement(spec)
|
||||
try:
|
||||
installed = version(req.name)
|
||||
except PackageNotFoundError:
|
||||
return False
|
||||
return req.specifier.contains(installed, prereleases=True)
|
||||
|
||||
|
||||
def _peers(decl: PythonDeclaration, home: Path) -> list[PythonDeclaration]:
|
||||
"""Enabled plugins other than *decl* itself (matched by directory, so a re-enable is not its own peer)."""
|
||||
return [d for d in enabled_declarations(home) if d.plugin_dir.resolve() != decl.plugin_dir.resolve()]
|
||||
|
||||
|
||||
def install_declaration(decl: PythonDeclaration, *, home: Path, project_root: Path,
|
||||
timeout: int = 900) -> list[str]:
|
||||
"""Install one plugin's applicable specs into the venv, resolved TOGETHER with every enabled
|
||||
peer's specs under core constraints, so installing A can never downgrade what enabled B needs
|
||||
(uv refuses instead). No-op when every spec is already satisfied. Returns what applies."""
|
||||
specs = applicable_specs(decl.specs)
|
||||
if specs and not decl.external and not all(_satisfied(s) for s in specs):
|
||||
resolve(union_specs([decl, *_peers(decl, home)]), core_constraints(project_root),
|
||||
dry_run=False, timeout=timeout)
|
||||
return specs
|
||||
|
||||
|
||||
# ── install-time glue shared by CLI, dashboard and pack installs ──────────────
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DepsOutcome:
|
||||
"""What happened to a freshly installed plugin's Python dependencies. ``status`` is one of
|
||||
``none`` (nothing declared / external runtime), ``installed``, ``failed`` (network, build, gate;
|
||||
the plugin stays installed and the loader warns at import), ``invalid`` (malformed declaration)."""
|
||||
status: str
|
||||
specs: tuple[str, ...] = ()
|
||||
detail: str = ""
|
||||
skipped: tuple[str, ...] = () # direct-URL requirements Hermes never installs
|
||||
|
||||
@property
|
||||
def message(self) -> str:
|
||||
text = _OUTCOME_MESSAGES[self.status](self)
|
||||
if self.skipped:
|
||||
text += (f"\nNot installed (direct URL requirements are left to you): "
|
||||
f"uv pip install {' '.join(repr(s) for s in self.skipped)}")
|
||||
return text.strip()
|
||||
|
||||
|
||||
_OUTCOME_MESSAGES: dict[str, Callable[[DepsOutcome], str]] = {
|
||||
"none": lambda o: "",
|
||||
"installed": lambda o: f"Installed Python dependencies: {', '.join(o.specs)}",
|
||||
"failed": lambda o: (f"Python dependencies not installed ({o.detail}). Run manually: "
|
||||
f"uv pip install {' '.join(o.specs)}"),
|
||||
"invalid": lambda o: f"Python dependency declaration ignored: {o.detail}",
|
||||
}
|
||||
|
||||
|
||||
def project_root() -> Path:
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def refuse_conflicting_candidate(plugin_dir: Path, *, home: Path) -> None:
|
||||
"""Install-time gate, run BEFORE a plugin tree is moved into place: a candidate whose deps cannot
|
||||
resolve with core + the enabled plugins is refused; a malformed declaration is refused too. A
|
||||
network failure during the dry run is not a verdict and lets the install continue."""
|
||||
try:
|
||||
check_candidate(read_declaration(plugin_dir), home=home, project_root=project_root())
|
||||
except DependencyConflict as exc:
|
||||
raise DependencyConflict(
|
||||
f"its Python dependencies conflict with Hermes or an enabled plugin:\n{exc}") from exc
|
||||
except DependencyInstallError as exc:
|
||||
logger.warning("Dependency pre-check skipped (%s); the install will retry for real", exc)
|
||||
except (ValueError, tomllib.TOMLDecodeError, yaml.YAMLError) as exc:
|
||||
raise ValueError(f"invalid Python dependency declaration: {exc}") from exc
|
||||
|
||||
|
||||
def install_for_plugin_dir(plugin_dir: Path) -> DepsOutcome:
|
||||
"""Install an installed plugin's declared deps into the venv. Never raises."""
|
||||
try:
|
||||
decl = read_declaration(plugin_dir)
|
||||
except (ValueError, tomllib.TOMLDecodeError, yaml.YAMLError) as exc:
|
||||
return DepsOutcome("invalid", detail=str(exc))
|
||||
skipped = () if decl.external else tuple(unsupported_specs(decl.specs))
|
||||
try:
|
||||
specs = install_declaration(decl, home=get_hermes_home(), project_root=project_root())
|
||||
except (DependencyConflict, DependencyInstallError) as exc:
|
||||
return DepsOutcome("failed", tuple(applicable_specs(decl.specs)), detail=_tail(str(exc), 300),
|
||||
skipped=skipped)
|
||||
return DepsOutcome("installed" if specs else "none", tuple(specs), skipped=skipped)
|
||||
|
||||
|
||||
# ── post-update re-apply ──────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class ReapplyReport:
|
||||
installed: list[str]
|
||||
dropped: list[tuple[str, str]] # (plugin name, reason)
|
||||
failed: str = ""
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return not self.failed
|
||||
|
||||
|
||||
def _drop_order(declarations: list[PythonDeclaration]) -> list[PythonDeclaration]:
|
||||
"""Plugins in the order they may be sacrificed on conflict: non-memory first, memory last."""
|
||||
return sorted(declarations, key=lambda d: (d.memory_provider, d.name))
|
||||
|
||||
|
||||
def _resolves(declarations: list[PythonDeclaration], constraints: list[str]) -> bool:
|
||||
try:
|
||||
resolve(union_specs(declarations), constraints, dry_run=True)
|
||||
except DependencyConflict:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _first_resolving_subset(declarations: list[PythonDeclaration], constraints: list[str]
|
||||
) -> tuple[list[PythonDeclaration], list[PythonDeclaration]]:
|
||||
"""Largest subset of *declarations* that resolves. Fast path: the whole union. Otherwise every
|
||||
plugin that cannot resolve on its own against core is a culprit and is dropped (never an innocent
|
||||
neighbour); what remains is peeled non-memory-first only for plugin-vs-plugin conflicts."""
|
||||
if _resolves(declarations, constraints):
|
||||
return list(declarations), []
|
||||
kept = [d for d in declarations if _resolves([d], constraints)]
|
||||
dropped = [d for d in declarations if d not in kept]
|
||||
order = _drop_order(kept)
|
||||
while kept and not _resolves(kept, constraints):
|
||||
victim = order.pop(0)
|
||||
kept.remove(victim)
|
||||
dropped.append(victim)
|
||||
return kept, dropped
|
||||
|
||||
|
||||
def reapply_all(*, project_root: Path, disable: Callable[[Path, str], None]) -> ReapplyReport:
|
||||
"""Re-install the union of every enabled plugin's deps after a venv rebuild. Conflicts drop
|
||||
non-memory plugins first and disable them through *disable(home, name)*; anything else (network,
|
||||
gate) is reported without changing plugin state."""
|
||||
per_home = [(home, enabled_declarations(home)) for home in dependency_homes()]
|
||||
everything = [d for _home, decls in per_home for d in decls]
|
||||
if not everything:
|
||||
return ReapplyReport(installed=[], dropped=[])
|
||||
constraints = core_constraints(project_root)
|
||||
try:
|
||||
kept, dropped = _first_resolving_subset(everything, constraints)
|
||||
specs = union_specs(kept)
|
||||
if specs:
|
||||
resolve(specs, constraints, dry_run=False)
|
||||
except DependencyInstallError as exc:
|
||||
return ReapplyReport(installed=[], dropped=[], failed=str(exc))
|
||||
report = ReapplyReport(installed=specs, dropped=[])
|
||||
for victim in dropped:
|
||||
home = next(h for h, decls in per_home if victim in decls)
|
||||
reason = "its Python dependencies no longer resolve against this Hermes"
|
||||
disable(home, victim.name)
|
||||
report.dropped.append((victim.name, reason))
|
||||
return report
|
||||
@@ -342,25 +342,46 @@ def _missing_env_specs(manifest: dict) -> list[dict]:
|
||||
return [s for s in env_specs if not get_env_value(s["name"])]
|
||||
|
||||
|
||||
def _print_python_dependencies(manifest: dict, console) -> None:
|
||||
"""Print declared ``python_dependencies`` with an install hint — Hermes never auto-installs
|
||||
plugin pip dependencies.
|
||||
def _refuse_conflicting_python_deps(tmp_target: Path, plugin_name: str) -> None:
|
||||
"""Dependency pre-check on the staged tree: a conflict with core or an enabled plugin refuses the
|
||||
install before anything is moved into place (nothing installed, nothing disabled)."""
|
||||
from hermes_cli.plugin_python_deps import DependencyConflict, refuse_conflicting_candidate
|
||||
try:
|
||||
refuse_conflicting_candidate(tmp_target, home=get_hermes_home())
|
||||
except DependencyConflict as exc:
|
||||
raise PluginOperationError(
|
||||
f"Plugin '{plugin_name}' was not installed: {exc}\n"
|
||||
"The plugin author should relax that requirement. To install the plugin anyway and manage "
|
||||
"its Python packages yourself: hermes plugins install <identifier> --no-deps") from exc
|
||||
except ValueError as exc:
|
||||
raise PluginOperationError(f"Plugin '{plugin_name}' was not installed: {exc}") from exc
|
||||
|
||||
See #64165.
|
||||
See #15220, #64165.
|
||||
"""
|
||||
deps = manifest.get("python_dependencies") or []
|
||||
if not isinstance(deps, list):
|
||||
|
||||
def _install_python_dependencies_quietly(target: Path, warnings: list[str]) -> list[str]:
|
||||
"""Dashboard variant: install, append a failure to *warnings*, return the applicable specs."""
|
||||
from hermes_cli.plugin_python_deps import install_for_plugin_dir
|
||||
outcome = install_for_plugin_dir(target)
|
||||
if outcome.status in ("failed", "invalid"):
|
||||
warnings.append(outcome.message)
|
||||
return list(outcome.specs)
|
||||
|
||||
|
||||
def _install_python_dependencies_for_key(key: str, console) -> None:
|
||||
"""``plugins enable`` variant: a user plugin enabled after a bare install gets its deps now."""
|
||||
entry = _find_plugin_entry(key)
|
||||
if entry is not None and entry[4]:
|
||||
_install_python_dependencies(Path(entry[4]), console)
|
||||
|
||||
|
||||
def _install_python_dependencies(target: Path, console, *, skip: bool = False) -> None:
|
||||
"""Install the plugin's declared Python deps (pyproject ``[project].dependencies`` or manifest
|
||||
``python_dependencies``) into the venv and report; the plugin stays installed on failure."""
|
||||
from hermes_cli.plugin_python_deps import install_for_plugin_dir
|
||||
outcome = install_for_plugin_dir(target) if not skip else None
|
||||
if outcome is None or outcome.status == "none":
|
||||
return
|
||||
deps = [d.strip() for d in deps if isinstance(d, str) and d.strip()]
|
||||
if not deps:
|
||||
return
|
||||
plugin_name = manifest.get("name", "this plugin")
|
||||
console.print(f"\n[bold]{plugin_name}[/bold] declares Python dependencies (not installed automatically):")
|
||||
for dep in deps:
|
||||
console.print(f" - {dep}")
|
||||
console.print(
|
||||
f"[dim]Install them yourself if needed: pip install {' '.join(repr(d) for d in deps)}[/dim]\n")
|
||||
style = {"installed": "green", "failed": "yellow", "invalid": "yellow"}[outcome.status]
|
||||
console.print(f"[{style}]{'✓' if outcome.status == 'installed' else '⚠'}[/{style}] {outcome.message}")
|
||||
|
||||
|
||||
def _prompt_plugin_env_vars(manifest: dict, console) -> None:
|
||||
@@ -683,11 +704,13 @@ def _install_plugin_core(
|
||||
ref: Optional[str] = None,
|
||||
scan_decision_cb=None,
|
||||
reviewed_pin: Optional[str] = None,
|
||||
python_deps: bool = True,
|
||||
) -> 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."""
|
||||
only when the checked-out revision is exactly that sha. *python_deps* False skips the
|
||||
dependency conflict gate (``--no-deps``: the user installs them by hand)."""
|
||||
requested_revision = _normalize_exact_revision(ref) if ref is not None else None
|
||||
try:
|
||||
git_url, subdir = _resolve_git_url(identifier)
|
||||
@@ -721,6 +744,8 @@ def _install_plugin_core(
|
||||
# Scan BEFORE anything is moved into place; raises PluginScanBlocked when blocked.
|
||||
_scan_plugin_tree(tmp_target, identifier, force=force, scan_decision_cb=scan_decision_cb,
|
||||
reviewed_pin=bool(reviewed_pin) and installed_revision == reviewed_pin)
|
||||
if python_deps:
|
||||
_refuse_conflicting_python_deps(tmp_target, plugin_name)
|
||||
|
||||
if target.exists() and not force:
|
||||
raise PluginOperationError(
|
||||
@@ -751,6 +776,7 @@ def cmd_install(
|
||||
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.
|
||||
|
||||
@@ -798,10 +824,12 @@ def cmd_install(
|
||||
try:
|
||||
if entry is not None:
|
||||
target, installed_manifest, installed_name = catalog.install_catalog_entry(
|
||||
entry, force=force, ref=ref, allow_removed=True, scan_decision_cb=_interactive_scan_decision)
|
||||
entry, force=force, ref=ref, allow_removed=True, scan_decision_cb=_interactive_scan_decision,
|
||||
python_deps=not no_deps)
|
||||
else:
|
||||
target, installed_manifest, installed_name = _install_plugin_core(
|
||||
identifier, force=force, ref=ref, scan_decision_cb=_interactive_scan_decision)
|
||||
identifier, force=force, ref=ref, scan_decision_cb=_interactive_scan_decision,
|
||||
python_deps=not no_deps)
|
||||
except PluginOperationError as e:
|
||||
_fail(console, f"[red]{'Blocked' if isinstance(e, PluginScanBlocked) else 'Error'}:[/red] {e}")
|
||||
if not _looks_like_plugin_dir(target):
|
||||
@@ -809,7 +837,7 @@ def cmd_install(
|
||||
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)
|
||||
_print_python_dependencies(installed_manifest, console)
|
||||
_install_python_dependencies(target, console, skip=no_deps)
|
||||
_display_after_install(target, identifier)
|
||||
|
||||
if enable is None:
|
||||
@@ -921,11 +949,13 @@ def _rescan_after_update(target: Path, name: str, console) -> None:
|
||||
|
||||
|
||||
def _post_pull_housekeeping(target: Path, console) -> None:
|
||||
"""After ``git pull``: drop stale ``__pycache__`` and copy any new ``.example`` files."""
|
||||
"""After ``git pull``: drop stale ``__pycache__``, copy any new ``.example`` files, and install
|
||||
dependencies the new revision declares (a version bump commonly adds or moves a package)."""
|
||||
# Same stale-bytecode class as the main checkout (#6207/#60242): the pull just changed .py files under
|
||||
# this plugin dir, so drop any __pycache__ compiled from the previous revision.
|
||||
_clear_plugin_bytecode(target)
|
||||
_copy_example_files(target, console)
|
||||
_install_python_dependencies(target, console)
|
||||
|
||||
|
||||
def _remove_plugin_core(target: Path) -> None:
|
||||
@@ -1094,6 +1124,7 @@ def cmd_enable(name: str, allow_tool_override: Optional[bool] = None) -> None:
|
||||
# Built-in tool override is a privileged grant; bundled plugins are trusted.
|
||||
if source == "bundled":
|
||||
return
|
||||
_install_python_dependencies_for_key(key, console)
|
||||
# When the manifest declares capabilities the consent screen is the canonical grant path
|
||||
# (it covers tools.override too); the legacy prompt then only runs on an explicit flag.
|
||||
# See #64228.
|
||||
@@ -1818,9 +1849,11 @@ def dashboard_install_plugin(
|
||||
|
||||
if enable:
|
||||
_set_plugin_enabled(installed_name, enable=True)
|
||||
deps = _install_python_dependencies_quietly(target, warnings)
|
||||
ap = target / "after-install.md"
|
||||
return {
|
||||
"ok": True, "plugin_name": installed_name, "warnings": warnings,
|
||||
"python_dependencies": deps,
|
||||
"missing_env": [s["name"] for s in _missing_env_specs(installed_manifest)],
|
||||
"after_install_path": str(ap) if ap.exists() else None, "enabled": enable,
|
||||
}
|
||||
@@ -1916,7 +1949,10 @@ def dashboard_update_user_plugin(name: str) -> dict[str, Any]:
|
||||
try:
|
||||
if sidecar:
|
||||
sha, changed = catalog.repin_catalog_plugin(target, sidecar)
|
||||
return {"ok": True, "name": name, "sha": sha, "unchanged": not changed}
|
||||
warnings: list[str] = []
|
||||
deps = _install_python_dependencies_quietly(target, warnings) if changed else []
|
||||
return {"ok": True, "name": name, "sha": sha, "unchanged": not changed,
|
||||
"python_dependencies": deps, "warnings": warnings}
|
||||
msg = _pull_plugin_update(
|
||||
target,
|
||||
lambda rec: (
|
||||
@@ -2134,7 +2170,8 @@ _PLUGIN_ACTIONS = {
|
||||
force=getattr(args, "force", False),
|
||||
enable=_tri_state_flag(args, "enable", "no_enable"),
|
||||
ref=getattr(args, "ref", None),
|
||||
allow_removed=getattr(args, "allow_removed", False)),
|
||||
allow_removed=getattr(args, "allow_removed", False),
|
||||
no_deps=getattr(args, "no_deps", False)),
|
||||
"search": lambda args: _catalog().cmd_search(
|
||||
getattr(args, "term", "") or "", json_output=getattr(args, "json", False)),
|
||||
"browse": lambda args: _catalog().cmd_search(""),
|
||||
|
||||
@@ -106,7 +106,7 @@ def removed_annotation(name: str, dir_path) -> Optional[str]:
|
||||
# ── Catalog-aware install / update ───────────────────────────────────────────
|
||||
|
||||
def install_catalog_entry(entry: PluginCatalogEntry, *, force: bool, ref: Optional[str] = None,
|
||||
allow_removed: bool = False, scan_decision_cb=None) -> tuple:
|
||||
allow_removed: bool = False, scan_decision_cb=None, python_deps: bool = True) -> tuple:
|
||||
"""``_install_plugin_core`` at the catalog pin (an explicit *ref* wins) + provenance sidecar.
|
||||
Returns the core's ``(target, manifest, installed_name)``."""
|
||||
from hermes_cli.plugins_cmd import _install_plugin_core
|
||||
@@ -114,7 +114,7 @@ def install_catalog_entry(entry: PluginCatalogEntry, *, force: bool, ref: Option
|
||||
raise_if_removed(entry.name, entry.repo)
|
||||
target, manifest, installed_name = _install_plugin_core(
|
||||
entry.install_identifier, force=force, ref=ref or entry.sha, scan_decision_cb=scan_decision_cb,
|
||||
reviewed_pin=entry.sha)
|
||||
reviewed_pin=entry.sha, python_deps=python_deps)
|
||||
write_catalog_sidecar(target, entry)
|
||||
return target, manifest, installed_name
|
||||
|
||||
@@ -147,6 +147,9 @@ def cmd_update_catalog(name: str, target: Path, sidecar: dict, console) -> None:
|
||||
raise SystemExit(1)
|
||||
verb = "updated to" if changed else "is already at catalog pin"
|
||||
console.print(f"[green]✓[/green] Plugin [bold]{name}[/bold] {verb} {sha[:8]}.")
|
||||
if changed:
|
||||
from hermes_cli.plugins_cmd import _install_python_dependencies
|
||||
_install_python_dependencies(target, console)
|
||||
|
||||
|
||||
# ── search / browse / info / validate ────────────────────────────────────────
|
||||
|
||||
@@ -217,13 +217,9 @@ class PluginLoaderMixin:
|
||||
)
|
||||
|
||||
def _warn_python_dependencies(self, manifest: PluginManifest) -> None:
|
||||
"""Warn about missing declared pip dependencies with an install hint — NEVER auto-install.
|
||||
|
||||
See #64165.
|
||||
python_dependencies is a declaration seam ONLY: Hermes validates and prints the requirements with an
|
||||
install hint but NEVER auto-installs them. The isolation design (constraints installs vs. vendored
|
||||
dirs vs. conflict-detection-and-refusal) is an explicitly deferred follow-up — see the round-2
|
||||
review on #64165 and #15220.
|
||||
"""Warn about declared pip dependencies missing at load time. Installing happens at
|
||||
``hermes plugins install``/``enable`` and after ``hermes update`` (``hermes_cli.plugin_python_deps``)
|
||||
under core constraints; the loader itself never installs — import time is not a consent point.
|
||||
"""
|
||||
deps = manifest.python_dependencies
|
||||
if not deps:
|
||||
@@ -233,9 +229,9 @@ class PluginLoaderMixin:
|
||||
if missing:
|
||||
logger.warning(
|
||||
"Plugin %s declares Python dependencies that are not "
|
||||
"installed: %s. Hermes does not install plugin dependencies "
|
||||
"automatically; install them yourself, e.g.: pip install %s",
|
||||
key, ", ".join(missing), " ".join(f"'{m}'" for m in missing),
|
||||
"installed: %s. Run `hermes plugins enable %s` to install them, "
|
||||
"or install them yourself: pip install %s",
|
||||
key, ", ".join(missing), key, " ".join(f"'{m}'" for m in missing),
|
||||
)
|
||||
else:
|
||||
logger.debug("Plugin %s python_dependencies satisfied: %s", key, ", ".join(deps))
|
||||
|
||||
@@ -36,7 +36,7 @@ _KNOWN_MANIFEST_FIELDS: Set[str] = {
|
||||
"pip_dependencies", "provides_browser_providers", "provides_web_providers",
|
||||
"manifest_version", "api_version", "requires_plugins", "python_dependencies", "config_schema",
|
||||
"license", "homepage", "tags", "capabilities", "emits", "listens", "hermes", "depends",
|
||||
"requires_hermes",
|
||||
"requires_hermes", "python_runtime",
|
||||
}
|
||||
|
||||
# Highest manifest schema version this Hermes understands.
|
||||
|
||||
@@ -29,6 +29,10 @@ def build_plugins_parser(subparsers, *, cmd_plugins: Callable) -> None:
|
||||
plugins_install.add_argument(
|
||||
"--allow-removed", action="store_true",
|
||||
help="DANGEROUS: bypass the catalog removed-plugin blocklist check")
|
||||
plugins_install.add_argument(
|
||||
"--no-deps", action="store_true",
|
||||
help="Skip the plugin's declared Python dependencies (no conflict check, nothing installed); "
|
||||
"you manage them yourself")
|
||||
_install_enable_group = plugins_install.add_mutually_exclusive_group()
|
||||
_install_enable_group.add_argument(
|
||||
"--enable", action="store_true",
|
||||
|
||||
@@ -80,6 +80,7 @@ from hermes_cli.update_cmd_deps import ( # noqa: F401
|
||||
_ensure_venv_pip, _install_psutil_android_compat, _is_android_python, _npm_bin_exists,
|
||||
_npm_lockfile_changed, _npm_manifest_paths, _npm_manifests_digest, _path_uid,
|
||||
_rebuild_desktop_after_update, _record_npm_lockfile_hash, _refresh_active_lazy_features,
|
||||
_reapply_plugin_python_dependencies,
|
||||
_refresh_active_memory_provider_dependencies, _refuse_update_if_venv_foreign_owned,
|
||||
_repair_node_deps_on_current_checkout, _restore_active_tool_dependencies,
|
||||
_sync_python_dependencies_after_pull, _update_node_dependencies,
|
||||
@@ -620,6 +621,7 @@ def _repair_venv_on_current_checkout(
|
||||
_m()._install_python_dependencies_with_optional_fallback(repair_prefix, env=repair_env, group="all")
|
||||
_m()._refresh_active_lazy_features(repair_prefix, env=repair_env, features=active_lazy_features)
|
||||
_m()._restore_active_tool_dependencies(active_tool_dependencies, repair_prefix, env=repair_env)
|
||||
_m()._reapply_plugin_python_dependencies()
|
||||
# Core ``.[all]`` install finished. Clear the generic core breadcrumb before the lazy-refresh phase —
|
||||
# that phase uses its own marker so a later lazy failure cannot be "healed" by clearing the core marker
|
||||
# based on a narrow 7-package import probe (#58004 review).
|
||||
@@ -710,6 +712,7 @@ def _repair_current_checkout(
|
||||
"to finish import-based venv repair.")
|
||||
_m()._restore_active_tool_dependencies(
|
||||
active_tool_dependencies, repair_prefix, env=repair_env)
|
||||
_m()._reapply_plugin_python_dependencies()
|
||||
current_checkout_complete = _repair_node_deps_on_current_checkout(
|
||||
_print_verified_update_completion, assume_yes=assume_yes, gateway_mode=gateway_mode,
|
||||
pre_update_snapshot_id=pre_update_snapshot_id,
|
||||
|
||||
@@ -388,6 +388,41 @@ def _refresh_active_memory_provider_dependencies() -> None:
|
||||
print(f" ⚠ {provider} dependencies failed to refresh: {exc}")
|
||||
|
||||
|
||||
def _reapply_plugin_python_dependencies() -> None:
|
||||
"""Re-install every enabled user plugin's declared Python deps after the venv was rebuilt (a
|
||||
``uv sync``/reinstall strips anything Hermes' own lock does not know). Non-memory plugins whose
|
||||
deps no longer resolve are disabled loudly, memory providers last. Never raises."""
|
||||
from hermes_cli.plugin_python_deps import reapply_all
|
||||
from hermes_cli.update_cmd import _m
|
||||
|
||||
def _disable(home, name: str) -> None:
|
||||
# Write through the real config writer (comments/defaults preserved), scoped to *home*.
|
||||
from hermes_cli.config import load_config, save_config
|
||||
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
||||
token = set_hermes_home_override(home)
|
||||
try:
|
||||
config = load_config()
|
||||
plugins = config.setdefault("plugins", {})
|
||||
plugins["enabled"] = sorted(set(plugins.get("enabled") or []) - {name})
|
||||
plugins["disabled"] = sorted(set(plugins.get("disabled") or []) | {name})
|
||||
save_config(config, merge_existing=True)
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
|
||||
try:
|
||||
report = reapply_all(project_root=_m().PROJECT_ROOT, disable=_disable)
|
||||
except Exception as exc: # the update must finish even if the plugin step blows up
|
||||
print(f" ⚠ Plugin Python dependencies not re-applied: {exc}")
|
||||
return
|
||||
if report.installed:
|
||||
print(f" ✓ Plugin Python dependencies re-applied: {', '.join(report.installed)}")
|
||||
for name, reason in report.dropped:
|
||||
print(f" ✗ Plugin '{name}' DISABLED: {reason}. Fix the plugin's declared dependencies, "
|
||||
f"then `hermes plugins enable {name}`.")
|
||||
if report.failed:
|
||||
print(f" ⚠ Plugin Python dependencies not re-applied: {report.failed}")
|
||||
|
||||
|
||||
def _is_android_python() -> bool:
|
||||
from hermes_cli.update_cmd import _m
|
||||
return _m().sys.platform == "android"
|
||||
@@ -1046,6 +1081,7 @@ def _sync_python_dependencies_after_pull(
|
||||
|
||||
# Heal memory-provider bridge packages last — the steps above may have stripped them.
|
||||
_m()._refresh_active_memory_provider_dependencies()
|
||||
_m()._reapply_plugin_python_dependencies()
|
||||
|
||||
# Remaining import failures are real breakage. Warn only — never roll back: `cannot import
|
||||
# name X` is also the stale-bytecode signature, which self-heals next launch.
|
||||
|
||||
@@ -354,6 +354,7 @@ def _reinstall_python_deps_after_zip(active_tool_dependencies) -> None:
|
||||
_m()._restore_active_tool_dependencies(active_tool_dependencies, install_prefix, env=install_env)
|
||||
# Parity with git-pull path: heal the active memory provider's bridge packages after the reinstall.
|
||||
_m()._refresh_active_memory_provider_dependencies()
|
||||
_m()._reapply_plugin_python_dependencies()
|
||||
|
||||
|
||||
def _update_via_zip(args, *, had_desktop_app_before_update: bool = False, _windows_gateway_resume=None) -> bool:
|
||||
|
||||
@@ -300,7 +300,8 @@ this Hermes understands still loads with a warning.
|
||||
| `manifest_version` | int | Manifest **file-format** version. Absent = `1`. Current max: `2`. Independent from `api_version`. |
|
||||
| `api_version` | int | Runtime **plugin API generation** the plugin targets (ctx surface / hook signatures). Deliberately a separate axis from `manifest_version` — an `api_version: 1` plugin can use a v2 manifest. |
|
||||
| `requires_plugins` | list | Inter-plugin dependencies: `- id: other-plugin` with optional `version_range: ">=1.0,<2"`. **Advisory**: a missing dependency logs a clear warning but the plugin still loads — probe at runtime with `ctx.has_plugin("other-plugin")`. Load **order** honors these edges: when A requires B, B's `register()` runs before A's (topological sort, alphabetical tiebreak; cycles warn and fall back to alphabetical order). |
|
||||
| `python_dependencies` | list of str | Declared pip requirements (e.g. `"requests>=2.0,<3"`). **Declaration seam only** — Hermes validates them, and `hermes plugins install` / `hermes plugins doctor` surface missing ones with a `pip install` hint, but Hermes **never auto-installs** them. Pin upper bounds. |
|
||||
| `python_dependencies` | list of str | PEP 508 requirements (e.g. `"requests>=2.0,<3"`). Installed into Hermes' venv on `hermes plugins install` / `enable` and **re-applied after every `hermes update`** (see [Python dependencies](#python-dependencies)). A `pyproject.toml` beside `plugin.yaml` with `[project].dependencies` is the equivalent, preferred form. |
|
||||
| `python_runtime` | str | `external` — the plugin manages its own interpreter/venv (sidecar pattern); Hermes installs nothing and leaves any `pyproject.toml` alone. |
|
||||
| `config_schema` | mapping | JSON-schema-ish description of keys under `plugins.entries.<id>.settings`: `api_url: {type: str, default: "", description: "...", required: false}`. Validated at load; mismatches log actionable warnings naming the key and expected type — never load failures. Types: `str`, `int`, `float`, `bool`, `list`, `dict` (plus JSON-schema aliases). |
|
||||
| `license` | str | SPDX-style license id (e.g. `MIT`). |
|
||||
| `homepage` | str | Project URL. |
|
||||
@@ -319,21 +320,57 @@ requires_plugins:
|
||||
- id: other-plugin
|
||||
version_range: ">=1.0,<2"
|
||||
python_dependencies:
|
||||
- "somepkg>=1.0,<2" # surfaced, never auto-installed
|
||||
- "somepkg>=1.0,<2" # installed on install/enable, re-applied after hermes update
|
||||
config_schema:
|
||||
api_url: {type: str, default: "", description: "Service endpoint"}
|
||||
```
|
||||
|
||||
:::note pip-dependency isolation is deferred
|
||||
`python_dependencies` is intentionally declare-and-surface only. Installing
|
||||
arbitrary packages into Hermes' shared venv is a conflict and supply-chain
|
||||
surface, so the install seam's isolation design (constraints-file installs
|
||||
against the host lock vs. per-plugin vendored dirs vs. conflict detection
|
||||
with refusal) is an explicitly deferred follow-up — see the round-2 review on
|
||||
[#64165](https://github.com/NousResearch/hermes-agent/issues/64165) and
|
||||
[#15220](https://github.com/NousResearch/hermes-agent/issues/15220). Plugin
|
||||
packs (#64166) build on these v2 fields.
|
||||
:::
|
||||
### Python dependencies
|
||||
|
||||
A directory plugin can bring its own PyPI packages. Declare them either in the manifest
|
||||
(`python_dependencies`, above) or, preferably, in a `pyproject.toml` next to `plugin.yaml`:
|
||||
|
||||
```toml
|
||||
[project]
|
||||
name = "my-plugin"
|
||||
version = "1.0.0"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"somepkg>=1.0,<2",
|
||||
"other[extra]>=3.11",
|
||||
]
|
||||
```
|
||||
|
||||
When both exist the `pyproject.toml` wins. What Hermes does with them:
|
||||
|
||||
- **Install / enable** — the declared packages are installed into Hermes' venv with
|
||||
`uv pip install` (pip fallback) under a **constraints file built from Hermes' own pinned
|
||||
dependencies**, so a plugin can never move a core package (httpx, pydantic, …) off the version
|
||||
Hermes was tested with. Environment markers (`; sys_platform == "win32"`) are honoured.
|
||||
- **Conflict = refusal, never a silent drop** — before the plugin tree is moved into place, its
|
||||
dependencies are dry-run resolved together with every already-enabled plugin's. A candidate that
|
||||
cannot resolve is *not installed* and the error names the conflict; existing plugins are untouched.
|
||||
- **`hermes update` re-applies them** — the update's `uv sync` rebuilds the venv from Hermes' lock
|
||||
and strips anything else. Afterwards Hermes walks every profile's enabled plugins and reinstalls
|
||||
their declared dependencies. If the union no longer resolves (a core pin moved), non-memory
|
||||
plugins are dropped one at a time until it does; each dropped plugin is **disabled with a loud
|
||||
message** naming it, and memory providers are kept over everything else, because a Hermes that
|
||||
boots without memory looks like data loss.
|
||||
- **`hermes plugins update`** re-runs the install for whatever the new revision declares.
|
||||
- **`--no-deps`** on `hermes plugins install` skips all of this for one plugin (no conflict gate,
|
||||
nothing installed) when you would rather manage its packages yourself.
|
||||
- **Opt out with `python_runtime: external`** — plugins that keep a heavy runtime (torch, native
|
||||
extensions) in their own sidecar venv and talk to it over a subprocess declare this in
|
||||
`plugin.yaml`; Hermes then installs nothing and the plugin never joins the shared resolution.
|
||||
- **Nothing to load is an error** — `hermes plugins validate` (and the catalog CI) fail a
|
||||
`plugin.yaml` with no `__init__.py`, `desktop/plugin.js` or `plugin.json` beside it. A pip-layout
|
||||
package whose code sits under `src/` behind an entry point needs a thin directory-plugin wrapper
|
||||
whose `pyproject.toml` depends on the package.
|
||||
- `security.allow_lazy_installs: false` disables all of this; the plugin installs, its dependencies
|
||||
do not, and the loader warns at import.
|
||||
|
||||
`HERMES_HOME/plugins/` survives `hermes update` and Desktop updates: the updater only rebuilds the
|
||||
venv and the checkout, never the home directory.
|
||||
|
||||
## Step 3: Write the tool schemas
|
||||
|
||||
|
||||
Reference in New Issue
Block a user