From be62ce61b89dd3d8b60b1be6826a271d96061640 Mon Sep 17 00:00:00 2001 From: ZdenekSrotyr Date: Wed, 6 May 2026 16:04:13 +0200 Subject: [PATCH] feat(cli): install SessionStart hook chaining self-upgrade then pull Single hook entry: 'agnes self-upgrade --quiet ... || true; agnes pull --quiet ... || true'. Shell semicolon guarantees ordering across every Claude Code version (no reliance on undocumented multi-hook execution semantics); each segment's || true preserves the original property that an upgrade failure does not abort the pull. --- cli/lib/hooks.py | 8 ++++++-- tests/test_lib_hooks.py | 28 +++++++++++++++++++++++++--- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/cli/lib/hooks.py b/cli/lib/hooks.py index 10990b3..19af530 100644 --- a/cli/lib/hooks.py +++ b/cli/lib/hooks.py @@ -24,7 +24,7 @@ from pathlib import Path # Substrings that identify "our" hook commands. Includes legacy `da sync` # so a workspace bootstrapped by an older CLI gets cleanly upgraded on the # next `agnes init` run. -_OUR_COMMAND_MARKERS = ("agnes pull", "agnes push", "da sync") +_OUR_COMMAND_MARKERS = ("agnes self-upgrade", "agnes pull", "agnes push", "da sync") def install_claude_hooks(workspace: Path) -> None: @@ -60,7 +60,11 @@ def install_claude_hooks(workspace: Path) -> None: existing.remove(entry) existing.append({"hooks": [{"type": "command", "command": command}]}) - _replace_or_add("SessionStart", "agnes pull --quiet 2>/dev/null || true") + _replace_or_add( + "SessionStart", + "agnes self-upgrade --quiet 2>/dev/null || true; " + "agnes pull --quiet 2>/dev/null || true", + ) _replace_or_add("SessionEnd", "agnes push --quiet 2>/dev/null || true") settings_path.write_text(json.dumps(cfg, indent=2) + "\n", encoding="utf-8") diff --git a/tests/test_lib_hooks.py b/tests/test_lib_hooks.py index d8cdec3..2db1e6f 100644 --- a/tests/test_lib_hooks.py +++ b/tests/test_lib_hooks.py @@ -14,9 +14,9 @@ def _read_settings(workspace: Path) -> dict: def test_install_creates_settings_file(tmp_path): install_claude_hooks(tmp_path) cfg = _read_settings(tmp_path) - assert cfg["hooks"]["SessionStart"] - assert "agnes pull --quiet" in cfg["hooks"]["SessionStart"][0]["hooks"][0]["command"] - assert cfg["hooks"]["SessionEnd"] + cmd = cfg["hooks"]["SessionStart"][0]["hooks"][0]["command"] + assert "agnes self-upgrade --quiet" in cmd + assert "agnes pull --quiet" in cmd assert "agnes push --quiet" in cfg["hooks"]["SessionEnd"][0]["hooks"][0]["command"] @@ -74,3 +74,25 @@ def test_install_handles_invalid_json(tmp_path, capsys): install_claude_hooks(tmp_path) captured = capsys.readouterr() assert "not valid JSON" in captured.err or "warning" in captured.err.lower() + + +def test_install_chains_self_upgrade_then_pull_in_one_entry(tmp_path): + install_claude_hooks(tmp_path) + cfg = _read_settings(tmp_path) + session_start = cfg["hooks"]["SessionStart"] + assert len(session_start) == 1, session_start + cmd = session_start[0]["hooks"][0]["command"] + assert "agnes self-upgrade --quiet" in cmd + assert "agnes pull --quiet" in cmd + # Order is encoded in the shell — self-upgrade must appear first + assert cmd.index("agnes self-upgrade") < cmd.index("agnes pull") + # Both segments carry || true so neither failure aborts the line + assert cmd.count("|| true") >= 2 + + +def test_install_idempotent_chained_entry(tmp_path): + install_claude_hooks(tmp_path) + install_claude_hooks(tmp_path) + cfg = _read_settings(tmp_path) + assert len(cfg["hooks"]["SessionStart"]) == 1 + assert len(cfg["hooks"]["SessionEnd"]) == 1