Files
hermes-agent/tests/hermes_cli/test_plugins_cmd.py
ethernet 284dbaf537 fix(pm): isolate bootstrap dependencies and unify YAML on ruamel
Activation reaches plugin discovery before the application dependencies
exist. Give PM its own locked Python project and runtime so it can install
or repair the application without importing that dependency tree.

Keep PM outside the application workspace. A shared uv workspace resolves
the application graph and cannot provide this isolation. Route mutations
through an isolated worker and preserve transaction callbacks, cancellation,
custom package registrations, and correlated receipts.

Use the same runtime builder for source installs and packaged payloads.
Keep offline wheelhouse support in that builder. Nix builds the independent
PM lock as a separate derivation. Refuse lazy-disabled bootstrap before
installing tools or dependencies.

Move first-party YAML readers and writers to ruamel. Keep the application
lock's transitive PyYAML requirements for third-party packages.

Verification:
- Focused canonical Python suite: 177 passed, 1 host-gated skip.
- Electron backend probes: 12 passed. Electron typecheck passed.
- Both uv locks, scoped lint, Bash syntax, and whitespace checks passed.
- Cold activation, corrupt-app repair, offline staging, and relocation ran.
- Built and exercised the Nix PM runtime and standalone YAML merge script.

Six broader caller test files retain the same 24 failing test IDs as an
archive of HEAD. The existing real-home guard blocks those tests before
they can exercise the affected paths. No full-suite pass is claimed.
Native Windows signing and full Bionic package execution remain unverified.
2026-09-11 12:23:51 -04:00

826 lines
30 KiB
Python

"""Tests for hermes_cli.plugins_cmd — the ``hermes plugins`` CLI subcommand."""
from __future__ import annotations
import logging
import os
import shutil
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
import hermes_yaml as yaml
from hermes_cli.plugins_cmd import (
PluginOperationError,
_copy_example_files,
_read_manifest,
_repo_name_from_url,
_resolve_git_executable,
_resolve_git_url,
_resolve_subdir_within,
_sanitize_plugin_name,
)
# ── _sanitize_plugin_name ─────────────────────────────────────────────────
class TestSanitizePluginName:
"""Reject path-traversal attempts while accepting valid names."""
def test_valid_simple_name(self, tmp_path):
target = _sanitize_plugin_name("my-plugin", tmp_path)
assert target == (tmp_path / "my-plugin").resolve()
def test_rejects_dot_dot(self, tmp_path):
with pytest.raises(ValueError, match="must not contain"):
_sanitize_plugin_name("../../etc/passwd", tmp_path)
# ── allow_subdir=True ──
# ── _resolve_git_url ──────────────────────────────────────────────────────
class TestResolveGitUrl:
"""Shorthand and full-URL resolution, with optional subdirectory."""
def test_url_with_fragment_subdir(self):
url, subdir = _resolve_git_url("https://github.com/owner/repo.git#my-plugin")
assert url == "https://github.com/owner/repo.git"
assert subdir == "my-plugin"
@pytest.mark.parametrize(
"identifier",
[
"https://github.com/owner/repo",
"https://github.com/owner/repo.git",
"https://github.com/owner",
"https://github.com/owner/repo/branches",
"https://github.com/owner//tree/main",
"https://gitlab.com/owner/repo/tree/main",
"git@github.com:owner/repo.git",
"file:///tmp/repo/tree/main",
],
)
def test_non_browser_urls_passthrough(self, identifier):
url, subdir = _resolve_git_url(identifier)
assert url == identifier
assert subdir is None
# ── _resolve_subdir_within ──────────────────────────────────────────────────
class TestResolveSubdirWithin:
"""Subdirectory resolution stays within the clone and rejects traversal."""
def test_valid_nested_subdir(self, tmp_path):
(tmp_path / "a" / "b" / "c").mkdir(parents=True)
result = _resolve_subdir_within(tmp_path, "a/b/c")
assert result == (tmp_path / "a" / "b" / "c").resolve()
def test_rejects_symlink_escape(self, tmp_path):
clone = tmp_path / "clone"
clone.mkdir()
outside = tmp_path / "outside"
outside.mkdir()
(clone / "link").symlink_to(outside)
with pytest.raises(PluginOperationError, match="escapes the repository"):
_resolve_subdir_within(clone, "link")
# ── _resolve_git_executable ─────────────────────────────────────────────────
class TestResolveGitExecutable:
"""Fallback resolution when bare ``git`` is not discoverable via ``PATH``."""
def teardown_method(self):
_resolve_git_executable.cache_clear()
def test_prefers_shutil_which(self):
import hermes_cli.plugins_cmd as pc
_resolve_git_executable.cache_clear()
with patch.object(pc.shutil, "which", return_value="/usr/local/bin/git"):
assert pc._resolve_git_executable() == "/usr/local/bin/git"
def test_fallback_posix_first_matching_path(self):
import hermes_cli.plugins_cmd as pc
_resolve_git_executable.cache_clear()
def _isfile(p: str) -> bool:
return p == "/usr/local/bin/git"
with patch.object(pc.shutil, "which", return_value=None):
with patch.object(pc.os, "name", "posix"):
with patch.object(pc.os.path, "isfile", side_effect=_isfile):
assert pc._resolve_git_executable() == "/usr/local/bin/git"
def test_git_pull_uses_resolved_executable(self, tmp_path):
import hermes_cli.plugins_cmd as pc
_resolve_git_executable.cache_clear()
with patch.object(
pc,
"_resolve_git_executable",
return_value="/resolved/git",
):
with patch.object(pc.subprocess, "run") as run:
# `git status --porcelain` (clean tree), `remote get-url origin`, then the pull.
run.side_effect = [
MagicMock(returncode=0, stdout="", stderr=""),
MagicMock(returncode=0, stdout="git@example.com:x.git\n", stderr=""),
MagicMock(returncode=0, stdout="Already up to date\n", stderr=""),
]
ok, msg = pc._git_pull_plugin_dir(tmp_path)
assert ok is True
assert run.call_count == 3
for call in run.call_args_list:
assert call.args[0][0] == "/resolved/git"
assert run.call_args_list[2].args[0][1:] == ["pull", "--ff-only"]
def test_git_pull_clean_tree_never_stashes(self, tmp_path):
import hermes_cli.plugins_cmd as pc
_resolve_git_executable.cache_clear()
with patch.object(pc, "_resolve_git_executable", return_value="/g"):
with patch.object(pc.subprocess, "run") as run:
run.side_effect = [
MagicMock(returncode=0, stdout="", stderr=""), # status
MagicMock(returncode=0, stdout="git@example.com:x.git\n", stderr=""), # remote get-url
MagicMock(returncode=0, stdout="Updated\n", stderr=""), # pull
]
ok, msg = pc._git_pull_plugin_dir(tmp_path)
assert ok is True
assert msg == "Updated"
commands = [c.args[0][1] for c in run.call_args_list]
assert "stash" not in commands
class TestGitPullPluginDirAutostash:
"""Real-git E2E: local edits in a plugin checkout must not block updates."""
@staticmethod
def _make_repos(tmp_path):
import subprocess as sp
def git(cwd, *args):
r = sp.run(["git", *args], cwd=str(cwd), capture_output=True, text=True)
assert r.returncode == 0, r.stderr
return r.stdout
origin = tmp_path / "origin"
origin.mkdir()
git(origin, "init", "-q", "-b", "main")
git(origin, "config", "user.email", "t@t")
git(origin, "config", "user.name", "t")
pad = "\n".join(f"# pad {i}" for i in range(12))
(origin / "plugin.py").write_text(
f"VALUE = 1\n{pad}\nOTHER = 'a'\n", encoding="utf-8"
)
git(origin, "add", ".")
git(origin, "commit", "-qm", "init")
checkout = tmp_path / "checkout"
git(tmp_path, "clone", "-q", str(origin), str(checkout))
git(checkout, "config", "user.email", "t@t")
git(checkout, "config", "user.name", "t")
return origin, checkout, git
@staticmethod
def _set_line(repo, prefix, new_line):
"""Replace the line starting with ``prefix`` in plugin.py, keep the rest."""
f = repo / "plugin.py"
lines = f.read_text(encoding="utf-8").splitlines()
lines = [new_line if ln.startswith(prefix) else ln for ln in lines]
f.write_text("\n".join(lines) + "\n", encoding="utf-8")
def test_dirty_checkout_pulls_and_reapplies_local_edit(self, tmp_path):
import hermes_cli.plugins_cmd as pc
if not pc._resolve_git_executable():
pytest.skip("git not available")
origin, checkout, git = self._make_repos(tmp_path)
# Upstream changes one line; local edit touches a DIFFERENT line.
self._set_line(origin, "VALUE", "VALUE = 2")
git(origin, "commit", "-qam", "bump value")
self._set_line(checkout, "OTHER", "OTHER = 'local'")
ok, msg = pc._git_pull_plugin_dir(checkout)
assert ok is True
content = (checkout / "plugin.py").read_text(encoding="utf-8")
assert "VALUE = 2" in content # update landed
assert "OTHER = 'local'" in content # local edit survived
assert "re-applied" in msg
# Clean re-apply drops the autostash entry.
assert git(checkout, "stash", "list").strip() == ""
def test_conflicting_local_edit_is_preserved_in_stash(self, tmp_path):
import hermes_cli.plugins_cmd as pc
if not pc._resolve_git_executable():
pytest.skip("git not available")
origin, checkout, git = self._make_repos(tmp_path)
# Upstream and local both change the SAME line → re-apply conflicts.
self._set_line(origin, "VALUE", "VALUE = 2")
git(origin, "commit", "-qam", "bump value")
self._set_line(checkout, "VALUE", "VALUE = 99")
ok, msg = pc._git_pull_plugin_dir(checkout)
assert ok is True
content = (checkout / "plugin.py").read_text(encoding="utf-8")
# Checkout is importable on the updated revision — no conflict markers.
assert "<<<<<<<" not in content
assert "VALUE = 2" in content
assert "preserved in git stash" in msg
# The local edit is recoverable from the kept stash entry.
stash_list = git(checkout, "stash", "list")
assert "hermes-plugin-update-autostash" in stash_list
stash_diff = git(checkout, "stash", "show", "-p", "stash@{0}")
assert "VALUE = 99" in stash_diff
def test_untracked_local_file_survives_update(self, tmp_path):
import hermes_cli.plugins_cmd as pc
if not pc._resolve_git_executable():
pytest.skip("git not available")
origin, checkout, git = self._make_repos(tmp_path)
self._set_line(origin, "VALUE", "VALUE = 2")
git(origin, "commit", "-qam", "bump value")
(checkout / "local_notes.txt").write_text("keep me\n", encoding="utf-8")
ok, msg = pc._git_pull_plugin_dir(checkout)
assert ok is True
assert (checkout / "local_notes.txt").read_text(encoding="utf-8") == "keep me\n"
assert "VALUE = 2" in (checkout / "plugin.py").read_text(encoding="utf-8")
def test_clean_checkout_unchanged_behavior(self, tmp_path):
import hermes_cli.plugins_cmd as pc
if not pc._resolve_git_executable():
pytest.skip("git not available")
origin, checkout, git = self._make_repos(tmp_path)
ok, msg = pc._git_pull_plugin_dir(checkout)
assert ok is True
assert "Already up to date" in msg
# ── _repo_name_from_url ──────────────────────────────────────────────────
class TestRepoNameFromUrl:
"""Extract plugin directory name from Git URLs."""
def test_https_with_dot_git(self):
assert (
_repo_name_from_url("https://github.com/owner/my-plugin.git") == "my-plugin"
)
# ── plugins_command dispatch ──────────────────────────────────────────────
# ── _read_manifest ────────────────────────────────────────────────────────
class TestReadManifest:
"""Manifest reading edge cases."""
def test_missing_file_returns_empty(self, tmp_path):
result = _read_manifest(tmp_path)
assert result == {}
def test_invalid_yaml_returns_empty_and_logs(self, tmp_path, caplog):
(tmp_path / "plugin.yaml").write_text(": : : bad yaml [[[", encoding="utf-8")
with caplog.at_level(logging.WARNING, logger="hermes_cli.plugins_cmd"):
result = _read_manifest(tmp_path)
assert result == {}
assert any("Failed to read plugin.yaml" in r.message for r in caplog.records)
def test_empty_file_returns_empty(self, tmp_path):
(tmp_path / "plugin.yaml").write_text("", encoding="utf-8")
result = _read_manifest(tmp_path)
assert result == {}
# ── cmd_install tests ─────────────────────────────────────────────────────────
class TestCmdInstall:
"""Test the install command."""
def test_install_requires_identifier(self):
from hermes_cli.plugins_cmd import cmd_install
with pytest.raises(SystemExit):
cmd_install("")
@patch("hermes_cli.plugins_cmd._resolve_git_url")
def test_install_validates_identifier(self, mock_resolve):
from hermes_cli.plugins_cmd import cmd_install
mock_resolve.side_effect = ValueError("Invalid identifier")
with pytest.raises(SystemExit) as exc_info:
cmd_install("invalid")
assert exc_info.value.code == 1
@patch("hermes_cli.plugins_cmd._display_after_install")
@patch("hermes_cli.plugins_cmd.shutil.move")
@patch("hermes_cli.plugins_cmd.shutil.rmtree")
@patch("hermes_cli.plugins_cmd._plugins_dir")
@patch("hermes_cli.plugins_cmd._read_manifest")
@patch("hermes_cli.plugins_cmd.subprocess.run")
def test_install_rejects_manifest_name_pointing_at_plugins_root(
self,
mock_run,
mock_read_manifest,
mock_plugins_dir,
mock_rmtree,
mock_move,
mock_display_after_install,
tmp_path,
):
from hermes_cli.plugins_cmd import cmd_install
plugins_dir = tmp_path / "plugins"
plugins_dir.mkdir()
mock_plugins_dir.return_value = plugins_dir
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
mock_read_manifest.return_value = {"name": "."}
with pytest.raises(SystemExit) as exc_info:
cmd_install("owner/repo", force=True)
assert exc_info.value.code == 1
assert plugins_dir not in [call.args[0] for call in mock_rmtree.call_args_list]
mock_move.assert_not_called()
mock_display_after_install.assert_not_called()
# ── cmd_update tests ─────────────────────────────────────────────────────────
class TestCmdUpdate:
"""Test the update command."""
@patch("hermes_cli.plugins_cmd._sanitize_plugin_name")
@patch("hermes_cli.plugins_cmd._plugins_dir")
@patch("hermes_cli.plugins_cmd.subprocess.run")
def test_update_git_pull_success(self, mock_run, mock_plugins_dir, mock_sanitize):
from hermes_cli.plugins_cmd import cmd_update
mock_plugins_dir_val = MagicMock()
mock_plugins_dir.return_value = mock_plugins_dir_val
mock_target = MagicMock()
mock_target.exists.return_value = True
mock_target.__truediv__ = lambda self, x: MagicMock(
exists=MagicMock(return_value=True)
)
mock_sanitize.return_value = mock_target
mock_run.side_effect = [
MagicMock(returncode=0, stdout="", stderr=""), # status: clean
MagicMock(returncode=0, stdout="git@example.com:x.git", stderr=""), # remote get-url
MagicMock(returncode=0, stdout="Updated", stderr=""), # pull
]
cmd_update("test-plugin")
assert mock_run.call_count == 3
@patch("hermes_cli.plugins_cmd._sanitize_plugin_name")
@patch("hermes_cli.plugins_cmd._plugins_dir")
def test_update_plugin_not_found(self, mock_plugins_dir, mock_sanitize):
from hermes_cli.plugins_cmd import cmd_update
mock_plugins_dir_val = MagicMock()
mock_plugins_dir_val.iterdir.return_value = []
mock_plugins_dir.return_value = mock_plugins_dir_val
mock_target = MagicMock()
mock_target.exists.return_value = False
mock_sanitize.return_value = mock_target
with pytest.raises(SystemExit) as exc_info:
cmd_update("nonexistent-plugin")
assert exc_info.value.code == 1
# ── cmd_remove tests ─────────────────────────────────────────────────────────
class TestCmdRemove:
"""Test the remove command."""
@patch("hermes_cli.plugins_cmd._sanitize_plugin_name")
@patch("hermes_cli.plugins_cmd._plugins_dir")
@patch("hermes_cli.plugins_cmd.shutil.rmtree")
def test_remove_deletes_plugin(self, mock_rmtree, mock_plugins_dir, mock_sanitize):
from hermes_cli.plugins_cmd import cmd_remove
mock_plugins_dir.return_value = MagicMock()
mock_target = MagicMock()
mock_target.exists.return_value = True
mock_sanitize.return_value = mock_target
cmd_remove("test-plugin")
mock_rmtree.assert_called_once_with(mock_target)
@patch("hermes_cli.plugins_cmd._sanitize_plugin_name")
@patch("hermes_cli.plugins_cmd._plugins_dir")
def test_remove_plugin_not_found(self, mock_plugins_dir, mock_sanitize):
from hermes_cli.plugins_cmd import cmd_remove
mock_plugins_dir_val = MagicMock()
mock_plugins_dir_val.iterdir.return_value = []
mock_plugins_dir.return_value = mock_plugins_dir_val
mock_target = MagicMock()
mock_target.exists.return_value = False
mock_sanitize.return_value = mock_target
with pytest.raises(SystemExit) as exc_info:
cmd_remove("nonexistent-plugin")
assert exc_info.value.code == 1
# ── cmd_list tests ─────────────────────────────────────────────────────────
class TestCmdList:
"""Test the list command."""
@patch("hermes_cli.plugins_cmd._plugins_dir")
def test_list_empty_plugins_dir(self, mock_plugins_dir):
from hermes_cli.plugins_cmd import cmd_list
mock_plugins_dir_val = MagicMock()
mock_plugins_dir_val.iterdir.return_value = []
mock_plugins_dir.return_value = mock_plugins_dir_val
cmd_list()
@patch("hermes_cli.plugins_cmd._plugins_dir")
@patch("hermes_cli.plugins_cmd._read_manifest")
def test_list_with_plugins(self, mock_read_manifest, mock_plugins_dir):
from hermes_cli.plugins_cmd import cmd_list
mock_plugins_dir_val = MagicMock()
mock_plugin_dir = MagicMock()
mock_plugin_dir.name = "test-plugin"
mock_plugin_dir.is_dir.return_value = True
mock_plugin_dir.__truediv__ = lambda self, x: MagicMock(
exists=MagicMock(return_value=False)
)
mock_plugins_dir_val.iterdir.return_value = [mock_plugin_dir]
mock_plugins_dir.return_value = mock_plugins_dir_val
mock_read_manifest.return_value = {"name": "test-plugin", "version": "1.0.0"}
cmd_list()
# ── _copy_example_files tests ─────────────────────────────────────────────────
class TestCopyExampleFiles:
"""Test example file copying."""
def test_copies_example_files(self, tmp_path):
from unittest.mock import MagicMock
console = MagicMock()
# Create example file
example_file = tmp_path / "config.yaml.example"
example_file.write_text("key: value", encoding="utf-8")
_copy_example_files(tmp_path, console)
# Should have created the file
assert (tmp_path / "config.yaml").exists()
console.print.assert_called()
def test_handles_copy_error_gracefully(self, tmp_path):
from unittest.mock import MagicMock, patch
console = MagicMock()
# Create example file
example_file = tmp_path / "config.yaml.example"
example_file.write_text("key: value", encoding="utf-8")
# Mock shutil.copy2 to raise an error
with patch(
"hermes_cli.plugins_cmd.shutil.copy2",
side_effect=OSError("Permission denied"),
):
# Should not raise, just warn
_copy_example_files(tmp_path, console)
# Should have printed a warning
assert any("Warning" in str(c) for c in console.print.call_args_list)
class TestPromptPluginEnvVars:
"""Tests for _prompt_plugin_env_vars."""
def test_prompts_for_missing_var_rich_format(self):
from hermes_cli.plugins_cmd import _prompt_plugin_env_vars
from unittest.mock import MagicMock, patch
console = MagicMock()
manifest = {
"name": "langfuse_tracing",
"requires_env": [
{
"name": "LANGFUSE_PUBLIC_KEY",
"description": "Public key",
"url": "https://langfuse.com",
"secret": False,
},
],
}
with patch("hermes_cli.config.get_env_value", return_value=None), \
patch("builtins.input", return_value="pk-lf-123"), \
patch("hermes_cli.config.save_env_value") as mock_save:
_prompt_plugin_env_vars(manifest, console)
mock_save.assert_called_once_with("LANGFUSE_PUBLIC_KEY", "pk-lf-123")
# Should show url hint
printed = " ".join(str(c) for c in console.print.call_args_list)
assert "langfuse.com" in printed
def test_secret_uses_masked_prompt(self):
from hermes_cli.plugins_cmd import _prompt_plugin_env_vars
from unittest.mock import MagicMock, patch
console = MagicMock()
manifest = {
"name": "test",
"requires_env": [{"name": "SECRET_KEY", "secret": True}],
}
with patch("hermes_cli.config.get_env_value", return_value=None), \
patch("hermes_cli.plugins_cmd.masked_secret_prompt", return_value="s3cret") as mock_prompt, \
patch("hermes_cli.config.save_env_value"):
_prompt_plugin_env_vars(manifest, console)
mock_prompt.assert_called_once()
# ── curses_radiolist ─────────────────────────────────────────────────────
class TestCursesRadiolist:
"""Test the curses_radiolist function."""
def test_non_tty_returns_default(self):
from hermes_cli.curses_ui import curses_radiolist
with patch("sys.stdin") as mock_stdin:
mock_stdin.isatty.return_value = False
result = curses_radiolist("Pick one", ["a", "b", "c"], selected=1)
assert result == 1
# ── Provider discovery helpers ───────────────────────────────────────────
class TestProviderDiscovery:
"""Test provider plugin discovery and config helpers."""
def test_save_context_engine(self, tmp_path, monkeypatch):
"""Saving a context engine persists to config.yaml."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
config_file = tmp_path / "config.yaml"
config_file.write_text("context:\n engine: compressor\n", encoding="utf-8")
from hermes_cli.plugins_cmd import _save_context_engine
_save_context_engine("lcm")
content = yaml.safe_load(config_file.read_text(encoding="utf-8"))
assert content["context"]["engine"] == "lcm"
def test_discover_context_engines_empty(self):
"""Discovery returns empty list when import fails."""
with patch("plugins.context_engine.discover_context_engines",
side_effect=ImportError("no module")):
from hermes_cli.plugins_cmd import _discover_context_engines
result = _discover_context_engines()
assert result == []
# ── Auto-activation fix ──────────────────────────────────────────────────
class TestNoAutoActivation:
"""Verify that plugin engines don't auto-activate when config says 'compressor'."""
def test_compressor_default_ignores_plugin(self):
"""When context.engine is 'compressor', a plugin-registered engine should NOT
be used — only explicit config triggers plugin engines."""
# This tests the run_agent.py logic indirectly by checking that the
# code path for default config doesn't call get_plugin_context_engine.
import run_agent as ra_module
source = Path(ra_module.__file__).read_text(encoding="utf-8")
# The old code had: "Even with default config, check if a plugin registered one"
# The fix removes this. Verify it's gone.
assert "Even with default config, check if a plugin registered one" not in source
# ── End-to-end subdirectory install ──────────────────────────────────────────
class TestSubdirInstallE2E:
"""Install a plugin that lives in a subdirectory of a real local git repo."""
@staticmethod
def _make_repo_with_subdir_plugin(repo_root: Path) -> None:
"""Create a git repo where the plugin lives in ``./my-plugin/`` and the
repo root holds unrelated docs/tests."""
import subprocess as sp
repo_root.mkdir(parents=True, exist_ok=True)
# Root-level noise: docs + tests that should NOT be installed.
(repo_root / "README.md").write_text("# Monorepo docs\n", encoding="utf-8")
(repo_root / "tests").mkdir()
(repo_root / "tests" / "test_x.py").write_text(
"def test_x():\n pass\n", encoding="utf-8"
)
# The actual plugin in a subdirectory.
plugin_dir = repo_root / "my-plugin"
plugin_dir.mkdir()
(plugin_dir / "plugin.yaml").write_text(
"name: my-plugin\nmanifest_version: 1\ndescription: A subdir plugin\n",
encoding="utf-8",
)
(plugin_dir / "__init__.py").write_text("# plugin entry\n", encoding="utf-8")
env = {
**os.environ,
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@t",
}
sp.run(["git", "init", "-q"], cwd=repo_root, check=True, env=env)
sp.run(["git", "add", "-A"], cwd=repo_root, check=True, env=env)
sp.run(
["git", "commit", "-q", "-m", "init"],
cwd=repo_root,
check=True,
env=env,
)
def test_installs_only_the_subdir_plugin(self, tmp_path, monkeypatch):
if shutil.which("git") is None:
pytest.skip("git not available")
from hermes_cli import plugins_cmd as pc
repo_root = tmp_path / "monorepo"
self._make_repo_with_subdir_plugin(repo_root)
plugins_dir = tmp_path / "installed"
plugins_dir.mkdir()
monkeypatch.setattr(pc, "_plugins_dir", lambda: plugins_dir)
identifier = f"file://{repo_root}#my-plugin"
target, manifest, name = pc._install_plugin_core(identifier, force=False)
# Installed under the plugin's own name, not the repo name.
assert name == "my-plugin"
assert manifest.get("name") == "my-plugin"
assert target == (plugins_dir / "my-plugin").resolve()
# The plugin's files are present...
assert (target / "plugin.yaml").exists()
assert (target / "__init__.py").exists()
# ...and the repo-root noise is NOT.
assert not (target / "README.md").exists()
assert not (target / "tests").exists()
def test_missing_subdir_raises(self, tmp_path, monkeypatch):
if shutil.which("git") is None:
pytest.skip("git not available")
from hermes_cli import plugins_cmd as pc
repo_root = tmp_path / "monorepo"
self._make_repo_with_subdir_plugin(repo_root)
plugins_dir = tmp_path / "installed"
plugins_dir.mkdir()
monkeypatch.setattr(pc, "_plugins_dir", lambda: plugins_dir)
identifier = f"file://{repo_root}#does-not-exist"
with pytest.raises(PluginOperationError, match="does not exist"):
pc._install_plugin_core(identifier, force=False)
def test_installs_portable_root_package_disabled(self, tmp_path, monkeypatch):
if shutil.which("git") is None:
pytest.skip("git not available")
import json
import subprocess as sp
from hermes_cli import plugins_cmd as pc
from hermes_cli.agent_plugins import PLUGIN_SCHEMA_V1
repo_root = tmp_path / "portable-repo"
repo_root.mkdir()
(repo_root / "plugin.json").write_text(
json.dumps({"$schema": PLUGIN_SCHEMA_V1, "name": "portable.test"})
)
env = {
**os.environ,
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@t",
}
sp.run(["git", "init", "-q"], cwd=repo_root, check=True, env=env)
sp.run(["git", "add", "-A"], cwd=repo_root, check=True, env=env)
sp.run(["git", "commit", "-q", "-m", "init"], cwd=repo_root, check=True, env=env)
plugins_dir = tmp_path / "installed"
plugins_dir.mkdir()
monkeypatch.setattr(pc, "_plugins_dir", lambda: plugins_dir)
target, manifest, name = pc._install_plugin_core(
f"file://{repo_root}", force=False
)
assert name == "portable.test"
assert manifest["name"] == "portable.test"
assert target == (plugins_dir / "portable.test").resolve()
assert pc._resolve_plugin_key("portable.test") == "portable.test"
def test_portable_manifest_is_visible_to_plugin_cli(tmp_path):
import json
from hermes_cli.agent_plugins import PLUGIN_SCHEMA_V1
from hermes_cli.plugins_cmd import _read_manifest_info
plugin = tmp_path / "portable"
plugin.mkdir()
(plugin / "plugin.json").write_text(
json.dumps(
{
"$schema": PLUGIN_SCHEMA_V1,
"name": "portable.test",
"version": "1.0.0",
"description": "Portable test plugin",
}
)
)
assert _read_manifest_info(plugin, "") == (
"portable.test",
"1.0.0",
"Portable test plugin",
"portable.test",
)