agnes-the-ai-analyst/tests/test_cli_analyst_setup_hooks.py
ZdenekSrotyr d0b7e122d6 feat(cli): smart local sync — Claude Code SessionStart/SessionEnd hooks + da sync --quiet
The analyst flow becomes a closed loop with the server-curated table catalog:

  - `da analyst setup` writes `<workspace>/.claude/settings.json` with two hooks:
      SessionStart → `da sync --quiet || true`        — pulls fresh RBAC-filtered parquets at session start
      SessionEnd   → `da sync --upload-only --quiet || true` — uploads session jsonl + CLAUDE.local.md
  - `|| true` keeps Claude Code unblocked when the server is down.
  - Workspace-level (not user-home) so the hooks fire only when Claude Code opens this analyst workspace.
  - `da sync --quiet` rewrites the CLI output for hook consumption — 0 stdout on success, single-line error on failure.
  - Existing settings.json is patched (deep-merged), not overwritten; malformed JSON is reported, not silently overwritten.

Tests cover: workspace bootstrap, hook insertion, malformed-json safety, quiet-mode output shape.
2026-05-01 20:25:27 +02:00

97 lines
3.7 KiB
Python

"""`_install_claude_hooks` writes SessionStart/End hooks idempotently into
a Claude settings file (workspace-level for analyst workspaces)."""
import json
from pathlib import Path
from cli.commands.analyst import _install_claude_hooks
def test_install_creates_settings_when_missing(tmp_path):
settings = tmp_path / ".claude" / "settings.json"
_install_claude_hooks(settings)
cfg = json.loads(settings.read_text())
starts = cfg["hooks"]["SessionStart"]
cmds = [h["command"] for e in starts for h in e["hooks"]]
assert any("da sync --quiet" in c and "--upload-only" not in c for c in cmds), cmds
ends = cfg["hooks"]["SessionEnd"]
end_cmds = [h["command"] for e in ends for h in e["hooks"]]
assert any("da sync --upload-only" in c for c in end_cmds), end_cmds
def test_install_preserves_existing_unrelated_hooks(tmp_path):
settings = tmp_path / ".claude" / "settings.json"
settings.parent.mkdir(parents=True)
settings.write_text(json.dumps({
"hooks": {
"PreToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}],
},
"permissions": {"allow": ["Bash(git status:*)"]},
"model": "sonnet",
}))
_install_claude_hooks(settings)
cfg = json.loads(settings.read_text())
# Unrelated hook event preserved
assert cfg["hooks"]["PreToolUse"][0]["hooks"][0]["command"] == "echo hi"
# Unrelated top-level keys preserved
assert cfg["permissions"]["allow"] == ["Bash(git status:*)"]
assert cfg["model"] == "sonnet"
# Our new hooks added
assert "SessionStart" in cfg["hooks"]
assert "SessionEnd" in cfg["hooks"]
def test_install_is_idempotent(tmp_path):
settings = tmp_path / ".claude" / "settings.json"
_install_claude_hooks(settings)
first = json.loads(settings.read_text())
_install_claude_hooks(settings)
second = json.loads(settings.read_text())
# No duplicate entries
assert first["hooks"]["SessionStart"] == second["hooks"]["SessionStart"]
assert first["hooks"]["SessionEnd"] == second["hooks"]["SessionEnd"]
assert len(second["hooks"]["SessionStart"]) == 1
assert len(second["hooks"]["SessionEnd"]) == 1
def test_install_replaces_old_da_sync_entry_without_duplicating(tmp_path):
"""If the user already has a `da sync` entry from a prior version, our
install replaces it cleanly rather than appending a second copy."""
settings = tmp_path / ".claude" / "settings.json"
settings.parent.mkdir(parents=True)
settings.write_text(json.dumps({
"hooks": {
"SessionStart": [
{"hooks": [{"type": "command", "command": "da sync"}]}, # old shape
{"hooks": [{"type": "command", "command": "echo not-ours"}]},
]
}
}))
_install_claude_hooks(settings)
cfg = json.loads(settings.read_text())
starts = cfg["hooks"]["SessionStart"]
assert len(starts) == 2 # one ours, one third-party
cmds = [h["command"] for e in starts for h in e["hooks"]]
assert "da sync --quiet 2>/dev/null || true" in cmds
assert "echo not-ours" in cmds
assert all(c == "echo not-ours" or "da sync --quiet" in c for c in cmds), cmds
def test_install_skips_malformed_existing_settings(tmp_path, capsys):
"""If the settings file is corrupted JSON, warn on stderr and bail —
don't crash the surrounding `da analyst setup` flow."""
settings = tmp_path / ".claude" / "settings.json"
settings.parent.mkdir(parents=True)
settings.write_text("{not valid json")
_install_claude_hooks(settings) # must not raise
captured = capsys.readouterr()
assert "not valid JSON" in captured.err
# File untouched
assert settings.read_text() == "{not valid json"