From 1aa9f49dd33888bba751f5554a5ed01acfddfe90 Mon Sep 17 00:00:00 2001 From: cafitac Date: Fri, 1 May 2026 23:24:09 +0900 Subject: [PATCH] feat: support isolated Hermes doctor checks --- .dev/refactor/roadmap.md | 1 + CHANGELOG.md | 2 +- docs/hermes-setup.md | 3 ++- hermit_agent/__main__.py | 5 +++-- hermit_agent/doctor.py | 15 ++++++++++----- tests/test_cli_defaults.py | 21 +++++++++++++++++---- tests/test_doctor.py | 13 +++++++++---- tests/test_doctor_cli.py | 15 +++++++++------ 8 files changed, 52 insertions(+), 23 deletions(-) diff --git a/.dev/refactor/roadmap.md b/.dev/refactor/roadmap.md index 2ec0d5e..105d131 100644 --- a/.dev/refactor/roadmap.md +++ b/.dev/refactor/roadmap.md @@ -118,6 +118,7 @@ Status: - Public setup docs now cover Claude Code, Codex, and Hermes Agent separately so each orchestrator path matches the actual setup and runtime boundary. - Hermes-facing install surfaces exist in three explicit modes: `hermit install --print-hermes-mcp-config`, `hermit install --fix-hermes-mcp`, and `hermit install --test-hermes-mcp`. - The Hermes-facing fix/test surfaces now also accept `--hermes-home` so registration and smoke can be exercised against an isolated Hermes config directory. +- `hermit doctor` now also accepts `--hermes-home` so the Hermes MCP diagnostic can inspect the same isolated config target before or after repair. - Live runtime delivery remains the existing Hermit MCP server path; the Hermes wrapper is a setup/health adapter, not a second runtime. - Release smoke has already verified npm and PyPI packages can print, register, and test the Hermes-facing MCP configuration without requiring OpenAI API-key onboarding. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e98180..14c4f3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,7 +39,7 @@ - Added `hermit install --print-hermes-mcp-config` as a print-only Hermes Agent registration aid; it does not mutate the user's Hermes config. - Added `hermit install --fix-hermes-mcp` as an explicit Hermes Agent MCP registration path that calls `hermes mcp add hermit-channel --command hermit --args mcp-server` only when requested. - Added `hermit install --test-hermes-mcp` as an explicit live MCP wiring smoke that runs `hermes mcp test hermit-channel` without mutating Hermes config. -- Added optional `--hermes-home` targeting for `hermit install --fix-hermes-mcp` and `hermit install --test-hermes-mcp` so registration/smoke can run against an isolated Hermes config directory instead of the operator's default `~/.hermes`. +- Added optional `--hermes-home` targeting for `hermit doctor`, `hermit install --fix-hermes-mcp`, and `hermit install --test-hermes-mcp` so isolated Hermes config directories can be diagnosed, repaired, and smoke-tested without touching the operator's default `~/.hermes`. - Tightened the Hermes MCP smoke so it treats `hermes mcp test hermit-channel` output such as `Server 'hermit-channel' not found in config` as a failure even when the Hermes CLI exits with status 0. - Tightened `hermit install --fix-hermes-mcp` so it answers Hermes Agent's tool-enable prompt, rejects `Cancelled` output even with exit code 0, and verifies the registration is visible in `hermes mcp list` before reporting success. - Added an optional `Hermes MCP` doctor diagnostic that checks for `hermit-channel -> hermit mcp-server` registration and tolerates current `hermes mcp list` text-only output. diff --git a/docs/hermes-setup.md b/docs/hermes-setup.md index 98cd0fd..acc38ee 100644 --- a/docs/hermes-setup.md +++ b/docs/hermes-setup.md @@ -65,11 +65,12 @@ Run the bounded live probe: hermit install --test-hermes-mcp ``` -If you want an isolated Hermes config during registration or smoke, point Hermit at a separate home: +If you want an isolated Hermes config during registration, smoke, or doctor checks, point Hermit at a separate home: ```bash hermit install --fix-hermes-mcp --hermes-home /tmp/hermes-home hermit install --test-hermes-mcp --hermes-home /tmp/hermes-home +hermit doctor --hermes-home /tmp/hermes-home ``` Under the hood this executes Hermes Agent's live MCP check for `hermit-channel` without mutating config beyond the explicit `--fix-hermes-mcp` path. diff --git a/hermit_agent/__main__.py b/hermit_agent/__main__.py index 5491e8b..3159171 100644 --- a/hermit_agent/__main__.py +++ b/hermit_agent/__main__.py @@ -109,6 +109,7 @@ def _build_doctor_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Diagnose or repair Hermit setup") parser.add_argument("--cwd", default=os.getcwd(), help="Working directory") parser.add_argument("--fix", action="store_true", help="Attempt common setup/runtime repairs automatically") + parser.add_argument("--hermes-home", default=None, help="Optional Hermes Agent config directory for isolated MCP diagnostics/repairs") return parser @@ -695,9 +696,9 @@ def main(): doctor_args = _build_doctor_parser().parse_args(sys.argv[2:]) if doctor_args.fix: - print(format_doctor_fix_summary(cwd=doctor_args.cwd)) + print(format_doctor_fix_summary(cwd=doctor_args.cwd, hermes_home=doctor_args.hermes_home)) else: - print(run_diagnostics(cwd=doctor_args.cwd).format()) + print(run_diagnostics(cwd=doctor_args.cwd, hermes_home=doctor_args.hermes_home).format()) return # ── codex-channels dispatch ────────────────────────────────────── diff --git a/hermit_agent/doctor.py b/hermit_agent/doctor.py index 0709f5a..4cbbf90 100644 --- a/hermit_agent/doctor.py +++ b/hermit_agent/doctor.py @@ -13,7 +13,7 @@ from enum import Enum from pathlib import Path -from .install_flow import resolve_hermit_mcp_stdio_entry, run_install, run_startup_self_heal +from .install_flow import _hermes_cli_env, resolve_hermit_mcp_stdio_entry, run_install, run_startup_self_heal class DiagStatus(Enum): @@ -256,7 +256,7 @@ def _server_transport(server: object) -> dict[str, object]: return server -def _check_hermes_mcp(cwd: str) -> DiagCheck: +def _check_hermes_mcp(cwd: str, hermes_home: str | None = None) -> DiagCheck: if shutil.which("hermes") is None: return DiagCheck( "Hermes MCP", @@ -264,10 +264,13 @@ def _check_hermes_mcp(cwd: str) -> DiagCheck: "hermes command not found — run `hermit install --print-hermes-mcp-config` after installing Hermes Agent", ) + hermes_env = _hermes_cli_env(hermes_home=hermes_home) + try: proc = subprocess.run( ["hermes", "mcp", "list", "--json"], cwd=cwd, + env=hermes_env, capture_output=True, text=True, timeout=15, @@ -283,6 +286,7 @@ def _check_hermes_mcp(cwd: str) -> DiagCheck: proc = subprocess.run( ["hermes", "mcp", "list"], cwd=cwd, + env=hermes_env, capture_output=True, text=True, timeout=15, @@ -336,7 +340,7 @@ def _check_hermes_mcp(cwd: str) -> DiagCheck: ) -def run_diagnostics(cwd: str | None = None, home: str | None = None) -> DiagReport: +def run_diagnostics(cwd: str | None = None, home: str | None = None, hermes_home: str | None = None) -> DiagReport: """Diagnose HermitAgent configuration. Testable by injecting cwd/home.""" cwd = cwd or os.getcwd() home = home or os.path.expanduser("~") @@ -348,18 +352,19 @@ def run_diagnostics(cwd: str | None = None, home: str | None = None) -> DiagRepo _check_sensitive_deny(), _check_local_backend(cwd), _check_agent_learner(home), - _check_hermes_mcp(cwd), + _check_hermes_mcp(cwd, hermes_home=hermes_home), ] return DiagReport(checks=checks) -def format_doctor_fix_summary(*, cwd: str) -> str: +def format_doctor_fix_summary(*, cwd: str, hermes_home: str | None = None) -> str: startup = run_startup_self_heal(cwd=cwd) install = run_install( cwd=cwd, assume_yes=True, skip_mcp_register=False, skip_codex=False, + hermes_home=hermes_home, ) lines = ["Hermit doctor --fix complete.", "", "Repairs:"] diff --git a/tests/test_cli_defaults.py b/tests/test_cli_defaults.py index 8da27e0..2aba7ed 100644 --- a/tests/test_cli_defaults.py +++ b/tests/test_cli_defaults.py @@ -116,6 +116,14 @@ def test_install_parser_accepts_explicit_hermes_home_for_isolated_smoke_runs(): assert ns.hermes_home == "/tmp/hermes-home" +def test_doctor_parser_accepts_explicit_hermes_home_for_isolated_checks(): + from hermit_agent.__main__ import _build_doctor_parser + + ns = _build_doctor_parser().parse_args(["--hermes-home", "/tmp/hermes-home"]) + + assert ns.hermes_home == "/tmp/hermes-home" + + def test_main_dispatches_install(monkeypatch, capsys): from hermit_agent import __main__ as main_mod from hermit_agent.install_flow import InstallSummary @@ -253,28 +261,33 @@ def test_main_dispatches_status_watch(monkeypatch): def test_main_dispatches_doctor(monkeypatch, capsys): from hermit_agent import __main__ as main_mod - monkeypatch.setattr(main_mod.sys, "argv", ["hermit-agent", "doctor", "--cwd", "/tmp/demo"]) + monkeypatch.setattr(main_mod.sys, "argv", ["hermit-agent", "doctor", "--cwd", "/tmp/demo", "--hermes-home", "/tmp/hermes-home"]) + + seen = [] class DummyReport: def format(self): return "HermitAgent Doctor — overall: PASS" - monkeypatch.setattr("hermit_agent.doctor.run_diagnostics", lambda **kwargs: DummyReport()) + monkeypatch.setattr("hermit_agent.doctor.run_diagnostics", lambda **kwargs: seen.append(kwargs) or DummyReport()) main_mod.main() assert "HermitAgent Doctor — overall: PASS" in capsys.readouterr().out + assert seen == [{"cwd": "/tmp/demo", "hermes_home": "/tmp/hermes-home"}] def test_main_dispatches_doctor_fix(monkeypatch, capsys): from hermit_agent import __main__ as main_mod - monkeypatch.setattr(main_mod.sys, "argv", ["hermit-agent", "doctor", "--cwd", "/tmp/demo", "--fix"]) - monkeypatch.setattr("hermit_agent.doctor.format_doctor_fix_summary", lambda **kwargs: "Hermit doctor --fix complete.") + monkeypatch.setattr(main_mod.sys, "argv", ["hermit-agent", "doctor", "--cwd", "/tmp/demo", "--fix", "--hermes-home", "/tmp/hermes-home"]) + seen = [] + monkeypatch.setattr("hermit_agent.doctor.format_doctor_fix_summary", lambda **kwargs: seen.append(kwargs) or "Hermit doctor --fix complete.") main_mod.main() assert "Hermit doctor --fix complete." in capsys.readouterr().out + assert seen == [{"cwd": "/tmp/demo", "hermes_home": "/tmp/hermes-home"}] def test_main_prints_startup_self_heal_summary_when_repairs_happen(monkeypatch, capsys): diff --git a/tests/test_doctor.py b/tests/test_doctor.py index df504cc..53ab556 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -128,16 +128,21 @@ class Result: stdout = json.dumps({"servers": {"hermit-channel": {"command": "hermit", "args": ["mcp-server"]}}}) stderr = "" - calls: list[list[str]] = [] + calls: list[dict[str, object]] = [] monkeypatch.setattr(doctor_mod.shutil, "which", lambda name: "/usr/local/bin/hermes" if name == "hermes" else None) - monkeypatch.setattr(doctor_mod.subprocess, "run", lambda args, **kwargs: calls.append(args) or Result()) + monkeypatch.setattr( + doctor_mod.subprocess, + "run", + lambda args, **kwargs: calls.append({"args": args, "env": kwargs.get("env")}) or Result(), + ) with tempfile.TemporaryDirectory() as project, tempfile.TemporaryDirectory() as home: - report = run_diagnostics(cwd=project, home=home) + hermes_home = Path(home) / ".hermes-isolated" + report = run_diagnostics(cwd=project, home=home, hermes_home=str(hermes_home)) chk = next(c for c in report.checks if c.name == "Hermes MCP") assert chk.status == DiagStatus.PASS assert "hermit-channel registered" in chk.message - assert calls == [["hermes", "mcp", "list", "--json"]] + assert calls == [{"args": ["hermes", "mcp", "list", "--json"], "env": {**__import__("os").environ, "HERMES_HOME": str(hermes_home)}}] def test_hermes_mcp_list_falls_back_when_json_flag_is_unsupported(monkeypatch): diff --git a/tests/test_doctor_cli.py b/tests/test_doctor_cli.py index ffbf3bf..1661680 100644 --- a/tests/test_doctor_cli.py +++ b/tests/test_doctor_cli.py @@ -5,6 +5,7 @@ def test_format_doctor_fix_summary_includes_repair_status(monkeypatch): + seen = {} monkeypatch.setattr( "hermit_agent.doctor.run_startup_self_heal", lambda **kwargs: StartupHealSummary( @@ -15,9 +16,9 @@ def test_format_doctor_fix_summary_includes_repair_status(monkeypatch): codex_runtime_status="missing", ), ) - monkeypatch.setattr( - "hermit_agent.doctor.run_install", - lambda **kwargs: InstallSummary( + def fake_run_install(**kwargs): + seen["run_install"] = kwargs + return InstallSummary( settings_path="/tmp/settings.json", gateway_api_key_created=True, gateway_api_key_present=True, @@ -26,10 +27,11 @@ def test_format_doctor_fix_summary_includes_repair_status(monkeypatch): codex_install_status="installed", codex_runtime_version="0.1.31", next_steps=["Run Hermit."], - ), - ) + ) + + monkeypatch.setattr("hermit_agent.doctor.run_install", fake_run_install) - text = format_doctor_fix_summary(cwd="/tmp/demo") + text = format_doctor_fix_summary(cwd="/tmp/demo", hermes_home="/tmp/hermes-home") assert "Hermit doctor --fix complete." in text assert "gateway=started" in text @@ -37,3 +39,4 @@ def test_format_doctor_fix_summary_includes_repair_status(monkeypatch): assert "codex=installed" in text assert "codex-facing surface remains: hermit-channel MCP" in text assert "codex integration runtime version: 0.1.31" in text + assert seen["run_install"]["hermes_home"] == "/tmp/hermes-home"