diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d3d2d5..6befa09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is inspired by Keep a Changelog and is intentionally lightweight whil ### Changed - Removed the legacy `install-codex`, `install-claude`, and `install-hermes` CLI commands. `agent-learner bootstrap` is now the only install entrypoint, with `--adapters` and per-adapter scope flags handling selective setup. - npm wrapper help, completion, and lane install forwarding now point to `bootstrap` instead of the removed top-level `install-*` aliases. +- Hermes user-scope bootstrap now auto-merges `pre_llm_call` and `on_session_end` hooks into an existing `~/.hermes/config.yaml`, writes a backup before updating the active config, and keeps `config.agent-learner.yaml` as a re-sync/reference snippet. ## [0.3.22] - 2026-04-25 diff --git a/docs/adapter-convergence.md b/docs/adapter-convergence.md index b3cccb5..e74cb70 100644 --- a/docs/adapter-convergence.md +++ b/docs/adapter-convergence.md @@ -27,8 +27,8 @@ After inspecting the current local environments: - prompt-time context injection via the real Hermes `pre_llm_call` shell hook - session-end capture via the real Hermes `on_session_end` shell hook and normalized `session_end` events - shared retrieval and event-processing core reused instead of adding a Hermes-specific learner -- current recommended rollout is explicit opt-in and project scope first -- installer writes a project-local `config.yaml` only when missing and always emits a mergeable `config.agent-learner.yaml` snippet for fail-safe adoption +- current recommended rollout is explicit opt-in, with user-scope bootstrap now validated against live Hermes runtime and project scope still available for isolated opt-in verification +- installer writes `config.yaml` only when missing, auto-merges required hooks into an existing user-scope active config with backup, and still emits `config.agent-learner.yaml` as a re-sync snippet ## Principle diff --git a/docs/install.md b/docs/install.md index 4ae9e7e..58cbb5e 100644 --- a/docs/install.md +++ b/docs/install.md @@ -207,11 +207,13 @@ The Hermes adapter wires two real Hermes shell hooks: Safe activation model: - user-scope install reuses the Hermes config and model/auth you already run - `config.yaml` is only created automatically when missing -- `config.agent-learner.yaml` is always written as a mergeable snippet for existing Hermes setups +- if `~/.hermes/config.yaml` already exists, bootstrap now backs it up and automatically merges the `pre_llm_call` and `on_session_end` hook entries into the active config +- `~/.hermes/config.yaml.agent-learner.bak` stores the pre-merge backup when an existing config is updated +- `config.agent-learner.yaml` is still written as a re-sync/reference snippet for existing Hermes setups - `AGENT_LEARNER_README.md` explains the generated files and merge path -If you already maintain your own Hermes config, review and merge the hook entries from -`~/.hermes/config.agent-learner.yaml` into the config you actually run. +This means the normal user-scope path is now one command: install with `bootstrap`, then run Hermes. +You only need to inspect `~/.hermes/config.agent-learner.yaml` if you want to review or re-sync the generated hook snippet manually. If you explicitly want an isolated project-local Hermes home instead, that path is still available: diff --git a/docs/quickstart.md b/docs/quickstart.md index a55d025..472641a 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -83,7 +83,7 @@ Notes: - Hermes is still marked experimental. - Default `bootstrap` now installs `codex,claude,hermes`. - Hermes default install scope is `user`. -- The installer writes `~/.hermes/config.agent-learner.yaml` and `~/.hermes/AGENT_LEARNER_README.md` so existing Hermes users can merge hook entries safely. +- The installer writes `~/.hermes/config.agent-learner.yaml` and `~/.hermes/AGENT_LEARNER_README.md`, and now auto-merges the hook entries into an existing `~/.hermes/config.yaml` after creating a backup. - `qa-hermes-smoke` now checks direct script output plus `hermes hooks list/doctor/test` runtime wiring. ## If you are validating a release diff --git a/src/agent_learner/adapters/hermes.py b/src/agent_learner/adapters/hermes.py index 53426a5..12ef87d 100644 --- a/src/agent_learner/adapters/hermes.py +++ b/src/agent_learner/adapters/hermes.py @@ -248,6 +248,7 @@ def main() -> int: ] CONFIG_SNIPPET_HEADER = "# agent-learner hermes hooks snippet\n" +CONFIG_BACKUP_NAME = "config.yaml.agent-learner.bak" ACTIVATION_NOTES = """# Agent Learner + Hermes This directory contains a project-local Hermes home for agent-learner hooks. @@ -278,6 +279,10 @@ def _command_for_script(script_path: Path, *, scope: str) -> str: def _render_config_yaml(*, prompt_command: str, auto_command: str) -> str: + return _render_hooks_block(prompt_command=prompt_command, auto_command=auto_command) + "hooks_auto_accept: false\n" + + +def _render_hooks_block(*, prompt_command: str, auto_command: str) -> str: return ( "hooks:\n" " pre_llm_call:\n" @@ -286,10 +291,96 @@ def _render_config_yaml(*, prompt_command: str, auto_command: str) -> str: " on_session_end:\n" f" - command: {auto_command!r}\n" " timeout: 15\n" - "hooks_auto_accept: false\n" ) +def _merge_hooks_section(existing_text: str, *, prompt_command: str, auto_command: str) -> str: + lines = existing_text.splitlines() + desired = { + "pre_llm_call": [f" - command: {prompt_command!r}", " timeout: 15"], + "on_session_end": [f" - command: {auto_command!r}", " timeout: 15"], + } + if not lines: + return _render_hooks_block(prompt_command=prompt_command, auto_command=auto_command).rstrip("\n") + if len(lines) == 1 and lines[0].strip() == "hooks: {}": + return _render_hooks_block(prompt_command=prompt_command, auto_command=auto_command).rstrip("\n") + + merged: list[str] = ["hooks:"] + index = 1 + seen_events: set[str] = set() + while index < len(lines): + line = lines[index] + stripped = line.strip() + if line.startswith(" ") and not line.startswith(" ") and stripped.endswith(":"): + event = stripped[:-1] + start = index + index += 1 + while index < len(lines): + next_line = lines[index] + next_stripped = next_line.strip() + if next_line.startswith(" ") and not next_line.startswith(" ") and next_stripped.endswith(":"): + break + index += 1 + block = lines[start:index] + block_text = "\n".join(block) + addition = desired.get(event) + if addition and addition[0] not in block_text: + block = block + addition + merged.extend(block) + seen_events.add(event) + continue + index += 1 + + for event in ("pre_llm_call", "on_session_end"): + if event in seen_events: + continue + merged.append(f" {event}:") + merged.extend(desired[event]) + return "\n".join(merged) + + +def _merge_user_config(config_path: Path, *, prompt_command: str, auto_command: str) -> Path | None: + original = config_path.read_text(encoding="utf-8") + backup_path = config_path.with_name(CONFIG_BACKUP_NAME) + lines = original.splitlines() + top_level_indexes: list[tuple[str, int]] = [] + for idx, line in enumerate(lines): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if not line.startswith((" ", "\t")) and ":" in stripped: + top_level_indexes.append((stripped.split(":", 1)[0], idx)) + + sections: dict[str, tuple[int, int]] = {} + for offset, (key, start) in enumerate(top_level_indexes): + end = top_level_indexes[offset + 1][1] if offset + 1 < len(top_level_indexes) else len(lines) + sections[key] = (start, end) + + existing_hooks_text = "" + if "hooks" in sections: + start, end = sections["hooks"] + existing_hooks_text = "\n".join(lines[start:end]) + hooks_block = _merge_hooks_section(existing_hooks_text, prompt_command=prompt_command, auto_command=auto_command) + + if "hooks" in sections: + start, end = sections["hooks"] + new_lines = lines[:start] + hooks_block.splitlines() + lines[end:] + else: + new_lines = lines + ([""] if lines and lines[-1].strip() else []) + hooks_block.splitlines() + + if not any(line.startswith("hooks_auto_accept:") for line in new_lines): + if new_lines and new_lines[-1].strip(): + new_lines.append("") + new_lines.append("hooks_auto_accept: false") + + merged_text = "\n".join(new_lines).rstrip("\n") + "\n" + if merged_text == original: + return backup_path if backup_path.exists() else None + backup_path.write_text(original, encoding="utf-8") + config_path.write_text(merged_text, encoding="utf-8") + return backup_path + + def _write_config_files(hermes_root: Path, *, scope: str, prompt_script: Path, auto_script: Path) -> list[Path]: prompt_command = _command_for_script(prompt_script, scope=scope) auto_command = _command_for_script(auto_script, scope=scope) @@ -300,6 +391,10 @@ def _write_config_files(hermes_root: Path, *, scope: str, prompt_script: Path, a if not config_path.exists(): written.append(write_text(config_path, config_text)) + elif scope == "user": + backup_path = _merge_user_config(config_path, prompt_command=prompt_command, auto_command=auto_command) + if backup_path is not None: + written.append(backup_path) written.append(write_text(snippet_path, CONFIG_SNIPPET_HEADER + config_text)) written.append(write_text(hermes_root / "AGENT_LEARNER_README.md", ACTIVATION_NOTES)) return written diff --git a/src/agent_learner/cli/main.py b/src/agent_learner/cli/main.py index d5582cf..7e075db 100644 --- a/src/agent_learner/cli/main.py +++ b/src/agent_learner/cli/main.py @@ -110,8 +110,13 @@ def emit_hermes_install_guidance(target: Path, *, scope: str, had_config: bool) print("[agent-learner] Hermes user-scope hooks installed.", file=sys.stderr) if had_config: + backup_path = hermes_root / "config.yaml.agent-learner.bak" print( - f"[agent-learner] Existing Hermes config preserved; review and merge {snippet_path} into your active Hermes config.", + f"[agent-learner] Existing Hermes config preserved and agent-learner hooks merged into your active Hermes config. Backup: {backup_path}", + file=sys.stderr, + ) + print( + f"[agent-learner] Review snippet for future re-syncs: {snippet_path}", file=sys.stderr, ) else: diff --git a/tests/test_cli_bootstrap.py b/tests/test_cli_bootstrap.py index 78e4fce..f27d3a6 100644 --- a/tests/test_cli_bootstrap.py +++ b/tests/test_cli_bootstrap.py @@ -149,19 +149,24 @@ def test_bootstrap_hermes_only_defaults_to_user_scope(monkeypatch, tmp_path: Pat assert "project-local opt-in" not in stderr -def test_bootstrap_hermes_preserves_existing_config_and_prints_merge_guidance(monkeypatch, tmp_path: Path, capsys) -> None: +def test_bootstrap_hermes_preserves_existing_config_and_auto_merges_hooks(monkeypatch, tmp_path: Path, capsys) -> None: hermes_root = tmp_path / ".hermes" hermes_root.mkdir(parents=True, exist_ok=True) config_path = hermes_root / "config.yaml" - config_path.write_text("model:\n provider: openai-codex\n", encoding="utf-8") + config_path.write_text("model:\n provider: openai-codex\nhooks: {}\n", encoding="utf-8") monkeypatch.setattr( "sys.argv", ["agent-learner", "bootstrap", "--target", str(tmp_path), "--adapters", "hermes"], ) assert cli_main() == 0 - assert config_path.read_text(encoding="utf-8") == "model:\n provider: openai-codex\n" + config_text = config_path.read_text(encoding="utf-8") + assert "provider: openai-codex" in config_text + assert "pre_llm_call" in config_text + assert "on_session_end" in config_text + assert (hermes_root / "config.yaml.agent-learner.bak").exists() stderr = capsys.readouterr().err - assert "Existing Hermes config preserved" in stderr + assert "Hermes user-scope hooks installed" in stderr + assert "merged into your active Hermes config" in stderr assert "config.agent-learner.yaml" in stderr diff --git a/tests/test_installers.py b/tests/test_installers.py index 7c2e0fb..8c89a85 100644 --- a/tests/test_installers.py +++ b/tests/test_installers.py @@ -4,6 +4,7 @@ from agent_learner.adapters import install_claude_adapter, install_codex_adapter, install_hermes_adapter from agent_learner.adapters.claude import install_claude_adapter_with_scope as install_claude_adapter_with_scope from agent_learner.adapters.codex import install_codex_adapter_with_scope +from agent_learner.adapters.hermes import install_hermes_adapter_with_scope from agent_learner.core.storage import read_project_registry, register_project, resolve_learning_root, should_register_project, storage_migration_marker_path @@ -108,6 +109,36 @@ def test_install_hermes_adapter_creates_expected_assets(tmp_path: Path) -> None: assert '--adapter", "hermes"' not in prompt_script +def test_install_hermes_user_scope_merges_hooks_into_existing_config_without_duplicates(tmp_path: Path) -> None: + hermes_root = tmp_path / ".hermes" + hermes_root.mkdir(parents=True, exist_ok=True) + config_path = hermes_root / "config.yaml" + config_path.write_text( + "model:\n" + " provider: openai-codex\n" + "hooks:\n" + " pre_llm_call:\n" + " - command: 'python existing-hook.py'\n" + " timeout: 5\n" + "hooks_auto_accept: false\n", + encoding="utf-8", + ) + + first_written = install_hermes_adapter_with_scope(tmp_path, scope="user") + second_written = install_hermes_adapter_with_scope(tmp_path, scope="user") + + config_text = config_path.read_text(encoding="utf-8") + assert "provider: openai-codex" in config_text + assert "python existing-hook.py" in config_text + assert config_text.count("hermes_prompt_context.py") == 1 + assert config_text.count("auto_session_learning.py") == 1 + assert "hooks_auto_accept: false" in config_text + assert (hermes_root / "config.agent-learner.yaml").exists() + assert (hermes_root / "config.yaml.agent-learner.bak").exists() + assert first_written + assert second_written + + def test_installers_are_independent(tmp_path: Path) -> None: install_codex_adapter(tmp_path) assert not (tmp_path / ".claude").exists()