From 5aebeabf236e0b6ebeb39bd18e309100995bf9b0 Mon Sep 17 00:00:00 2001 From: ZdenekSrotyr Date: Mon, 4 May 2026 17:53:20 +0200 Subject: [PATCH] feat(cli-lib): cli/lib/hooks.py:install_claude_hooks --- cli/lib/__init__.py | 1 + cli/lib/hooks.py | 66 +++++++++++++++++++++++++++++++++++ tests/test_lib_hooks.py | 77 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 cli/lib/__init__.py create mode 100644 cli/lib/hooks.py create mode 100644 tests/test_lib_hooks.py diff --git a/cli/lib/__init__.py b/cli/lib/__init__.py new file mode 100644 index 0000000..8d8f1be --- /dev/null +++ b/cli/lib/__init__.py @@ -0,0 +1 @@ +"""Shared library helpers for the agnes CLI.""" diff --git a/cli/lib/hooks.py b/cli/lib/hooks.py new file mode 100644 index 0000000..10990b3 --- /dev/null +++ b/cli/lib/hooks.py @@ -0,0 +1,66 @@ +"""Workspace-scoped Claude Code hook installer. + +Lifted from `cli/commands/analyst.py:_install_claude_hooks` (which gets +deleted in Task 18) so `agnes init` and any future caller can use it +without dragging in the deleted command module. + +Design notes: +- Workspace-scoped (`/.claude/settings.json`), NOT user-home. + The hooks fire only when Claude Code opens this workspace. +- Idempotent: second invocation drops a prior `agnes pull` / `da sync` / + `agnes push` entry (matched by command substring) and appends fresh entries. + Third-party hooks (mixed entries, foreign commands) are left alone. +- Uses `|| true` in the hook command so the hook never blocks a session on + a transient sync error. +""" + +from __future__ import annotations + +import json +import sys +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") + + +def install_claude_hooks(workspace: Path) -> None: + """Install SessionStart->`agnes pull` and SessionEnd->`agnes push` hooks. + + Idempotent. Workspace-scoped (writes `/.claude/settings.json`). + Preserves third-party hooks and other event types. + """ + settings_path = workspace / ".claude" / "settings.json" + settings_path.parent.mkdir(parents=True, exist_ok=True) + + if settings_path.exists(): + try: + cfg = json.loads(settings_path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + print( + f"Warning: {settings_path} is not valid JSON; skipping hook install.", + file=sys.stderr, + ) + return + else: + cfg = {} + + hooks = cfg.setdefault("hooks", {}) + + def _replace_or_add(event: str, command: str) -> None: + existing = hooks.setdefault(event, []) + for entry in list(existing): + entry_cmds = [h.get("command", "") for h in entry.get("hooks", [])] + if entry_cmds and all( + any(marker in c for marker in _OUR_COMMAND_MARKERS) for c in entry_cmds + ): + existing.remove(entry) + existing.append({"hooks": [{"type": "command", "command": command}]}) + + _replace_or_add("SessionStart", "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 new file mode 100644 index 0000000..9db538c --- /dev/null +++ b/tests/test_lib_hooks.py @@ -0,0 +1,77 @@ +"""Tests for cli/lib/hooks.py:install_claude_hooks.""" + +import json +from pathlib import Path + +import pytest + +from cli.lib.hooks import install_claude_hooks + + +def _read_settings(workspace: Path) -> dict: + return json.loads((workspace / ".claude" / "settings.json").read_text()) + + +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"] + assert "agnes push --quiet" in cfg["hooks"]["SessionEnd"][0]["hooks"][0]["command"] + + +def test_install_idempotent(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 + + +def test_install_replaces_old_da_sync_entries(tmp_path): + """Hook from a pre-rewrite workspace gets replaced cleanly.""" + settings_path = tmp_path / ".claude" / "settings.json" + settings_path.parent.mkdir(parents=True) + settings_path.write_text(json.dumps({ + "hooks": { + "SessionStart": [{"hooks": [{"type": "command", "command": "da sync --quiet"}]}], + "SessionEnd": [{"hooks": [{"type": "command", "command": "da sync --upload-only --quiet"}]}], + } + })) + install_claude_hooks(tmp_path) + cfg = _read_settings(tmp_path) + assert len(cfg["hooks"]["SessionStart"]) == 1 + assert "agnes pull" in cfg["hooks"]["SessionStart"][0]["hooks"][0]["command"] + assert "da sync" not in cfg["hooks"]["SessionStart"][0]["hooks"][0]["command"] + + +def test_install_preserves_third_party_hooks(tmp_path): + settings_path = tmp_path / ".claude" / "settings.json" + settings_path.parent.mkdir(parents=True) + settings_path.write_text(json.dumps({ + "hooks": { + "SessionStart": [{"hooks": [{"type": "command", "command": "echo hi from another tool"}]}], + "PreToolUse": [{"hooks": [{"type": "command", "command": "echo pre"}]}], + } + })) + install_claude_hooks(tmp_path) + cfg = _read_settings(tmp_path) + starts = cfg["hooks"]["SessionStart"] + assert any("echo hi from another tool" in s["hooks"][0]["command"] for s in starts) + assert any("agnes pull" in s["hooks"][0]["command"] for s in starts) + assert cfg["hooks"]["PreToolUse"][0]["hooks"][0]["command"] == "echo pre" + + +def test_install_handles_missing_settings_file(tmp_path): + install_claude_hooks(tmp_path) + assert (tmp_path / ".claude" / "settings.json").exists() + + +def test_install_handles_invalid_json(tmp_path, capsys): + settings_path = tmp_path / ".claude" / "settings.json" + settings_path.parent.mkdir(parents=True) + settings_path.write_text("not valid json {") + install_claude_hooks(tmp_path) + captured = capsys.readouterr() + assert "not valid JSON" in captured.err or "warning" in captured.err.lower()