feat(cli-lib): cli/lib/hooks.py:install_claude_hooks
This commit is contained in:
parent
d25d075ed2
commit
5aebeabf23
3 changed files with 144 additions and 0 deletions
1
cli/lib/__init__.py
Normal file
1
cli/lib/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Shared library helpers for the agnes CLI."""
|
||||
66
cli/lib/hooks.py
Normal file
66
cli/lib/hooks.py
Normal file
|
|
@ -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 (`<workspace>/.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 `<workspace>/.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")
|
||||
77
tests/test_lib_hooks.py
Normal file
77
tests/test_lib_hooks.py
Normal file
|
|
@ -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()
|
||||
Loading…
Reference in a new issue