"""Tests for cli/lib/hooks.py:install_claude_hooks.""" import json from pathlib import Path from cli.lib.hooks import ( install_claude_hooks, maybe_refresh_claude_hooks, workspace_has_agnes_hooks, ) def _read_settings(workspace: Path) -> dict: return json.loads((workspace / ".claude" / "settings.json").read_text()) def _commands_for(cfg: dict, event: str) -> list[str]: """Flatten the per-event command list — each entry has a list of hooks, each hook has a `command` field. We treat each entry as one command for assertion purposes (matches the install_claude_hooks contract: one entry per command).""" return [ entry["hooks"][0]["command"] for entry in cfg["hooks"].get(event, []) if entry.get("hooks") ] def test_install_creates_settings_file(tmp_path): install_claude_hooks(tmp_path) cfg = _read_settings(tmp_path) starts = _commands_for(cfg, "SessionStart") # SessionStart has three entries: (1) capture-session as the very first # so the hook stdin (transcript_path) is appended to the queue before # any other hook runs; (2) chained self-upgrade ; pull — self-upgrade # runs first so a wire-protocol bump lands before pull tries to use # the new CLI; (3) refresh-marketplace as a separate entry so a # failure (e.g. fresh workspace with no clone) doesn't suppress the # data pull above. # # `agnes push` is NOT in SessionStart — the queue mechanism handles # orphans on the next SessionEnd, so the old self-heal entry was # redundant + would re-upload the just-starting (empty) session. assert len(starts) == 3 capture = next((c for c in starts if "agnes capture-session" in c), None) assert capture is not None, "Expected SessionStart capture-session entry" assert capture.startswith("bash -c "), ( f"capture-session hook must be wrapped in bash -c for Windows; got: {capture!r}" ) assert not any("agnes push" in c for c in starts), ( f"agnes push must NOT be in SessionStart; got: {starts!r}" ) chain = next( (c for c in starts if "agnes self-upgrade" in c and "agnes pull" in c), None, ) assert chain is not None, ( "Expected one SessionStart entry chaining self-upgrade and pull" ) assert "agnes self-upgrade --quiet" in chain assert "agnes pull --quiet" in chain # The refresh-marketplace command is wrapped in `bash -c "..."` so the # `2>/dev/null || true` shell syntax is interpreted on Windows, where # Claude Code runs hook commands directly without invoking a shell. refresh = next((c for c in starts if "agnes refresh-marketplace" in c), None) assert refresh is not None assert refresh.startswith("bash -c "), ( f"refresh-marketplace hook must be wrapped in bash -c for Windows; got: {refresh!r}" ) # Hook is now a detector — `--check` only. Plugin install/update # happens in the `/update-agnes-plugins` slash command instead. # Pinning the flag here prevents an accidental regression to the old # `--quiet` form (which performed a full reconcile silently). assert "--check" in refresh, ( f"refresh-marketplace hook must use --check (detector mode); got: {refresh!r}" ) assert "--quiet" not in refresh, ( f"refresh-marketplace hook must NOT use --quiet (removed flag); got: {refresh!r}" ) ends = _commands_for(cfg, "SessionEnd") assert len(ends) == 1 assert "agnes push --quiet" in ends[0] def test_all_installed_hooks_are_bash_wrapped(tmp_path): """Pin the invariant: every Agnes-managed SessionStart / SessionEnd entry must be wrapped in `bash -c "..."`. Claude Code on Windows runs hook commands directly (no shell), so any unwrapped entry that relies on shell syntax (`;` chains, `2>/dev/null` redirection, `|| true` short-circuit) fails silently on Windows. The previous self-upgrade+pull chain shipped unwrapped — this test catches a regression to that state.""" install_claude_hooks(tmp_path) cfg = _read_settings(tmp_path) starts = _commands_for(cfg, "SessionStart") ends = _commands_for(cfg, "SessionEnd") for cmd in starts + ends: assert cmd.startswith("bash -c "), ( f"Hook must be wrapped in bash -c for Windows; got: {cmd!r}" ) def test_install_idempotent(tmp_path): install_claude_hooks(tmp_path) install_claude_hooks(tmp_path) cfg = _read_settings(tmp_path) # Three SessionStart entries (capture-session + chained self-upgrade/pull # + refresh-marketplace), one SessionEnd entry (push). Re-install must # NOT duplicate them. assert len(cfg["hooks"]["SessionStart"]) == 3 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 — legacy `da sync` entries are removed, both new agnes hooks land in their place.""" 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) starts = _commands_for(cfg, "SessionStart") assert len(starts) == 3 assert any("agnes capture-session" in c for c in starts) assert any("agnes pull" in c for c in starts) assert any("agnes refresh-marketplace" in c for c in starts) # `agnes push` lives only in SessionEnd now. assert not any("agnes push" in c for c in starts) # Legacy command must be gone from BOTH starts. assert not any("da sync" in c for c in starts) def test_install_replaces_prior_single_pull_entry(tmp_path): """Workspaces bootstrapped by a CLI version that only installed a single SessionStart entry (`agnes pull`, no refresh-marketplace) must upgrade to the three-entry layout on the next install — not end up stacking the new entries on top of the old one.""" 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": "agnes pull --quiet 2>/dev/null || true"}]}, ], } })) install_claude_hooks(tmp_path) cfg = _read_settings(tmp_path) starts = _commands_for(cfg, "SessionStart") assert len(starts) == 3 assert any("agnes capture-session" in c for c in starts) assert any("agnes pull" in c for c in starts) assert any("agnes refresh-marketplace" in c for c in starts) assert not any("agnes push" in c for c in starts) def test_install_replaces_v0_43_chained_self_upgrade_pull_entry(tmp_path): """Workspaces bootstrapped on v0.43.0 had a single SessionStart entry chaining `agnes self-upgrade; agnes pull` in one shell line. Upgrading those workspaces to v0.44.0+ must collapse that entry and re-install the new two-entry layout — not stack the v0.44 entries on top of the v0.43 chained one (which would re-run self-upgrade twice on every session and leave the old format around forever). """ 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": ( "agnes self-upgrade --quiet 2>/dev/null || true; " "agnes pull --quiet 2>/dev/null || true" )}]}, ], "SessionEnd": [ {"hooks": [{"type": "command", "command": "agnes push --quiet 2>/dev/null || true"}]}, ], } })) install_claude_hooks(tmp_path) cfg = _read_settings(tmp_path) starts = _commands_for(cfg, "SessionStart") # Exactly three entries — the v0.43 chained line was replaced, not stacked. assert len(starts) == 3, starts chain = next( (c for c in starts if "agnes self-upgrade" in c and "agnes pull" in c), None, ) assert chain is not None assert any("agnes capture-session" in c for c in starts) assert any("agnes refresh-marketplace" in c for c in starts) assert not any("agnes push" in c for c in starts) # SessionEnd untouched (single push entry). ends = _commands_for(cfg, "SessionEnd") assert len(ends) == 1 assert "agnes push --quiet" in ends[0] def test_install_replaces_old_quiet_refresh_with_check(tmp_path): """A workspace bootstrapped before the slash-command split has the old `--quiet` form in its refresh-marketplace SessionStart entry. The next `agnes init` must replace that entry with the new `--check` form, NOT stack the new entry alongside the old one (which would re-run the full reconcile every session — exactly the behaviour we just moved behind the slash command). """ 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": ( "agnes self-upgrade --quiet 2>/dev/null || true; " "agnes pull --quiet 2>/dev/null || true" )}]}, {"hooks": [{"type": "command", "command": ( 'bash -c "agnes refresh-marketplace --quiet 2>/dev/null || true"' )}]}, {"hooks": [{"type": "command", "command": ( 'bash -c "agnes push --quiet 2>/dev/null || true"' )}]}, ], "SessionEnd": [ {"hooks": [{"type": "command", "command": ( 'bash -c "( nohup agnes push --quiet /dev/null 2>&1 & ) ; true"' )}]}, ], } })) install_claude_hooks(tmp_path) cfg = _read_settings(tmp_path) starts = _commands_for(cfg, "SessionStart") # Exactly one refresh-marketplace entry remains (no stacking). refresh_entries = [c for c in starts if "agnes refresh-marketplace" in c] assert len(refresh_entries) == 1, refresh_entries refresh = refresh_entries[0] assert "--check" in refresh, ( f"old --quiet entry must have been rewritten to --check; got: {refresh!r}" ) assert "--quiet" not in refresh, ( f"old --quiet form must be gone after re-init; got: {refresh!r}" ) 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 = _commands_for(cfg, "SessionStart") # Third-party entry stays + all three agnes entries get added. assert len(starts) == 4 assert any("echo hi from another tool" in c for c in starts) assert any("agnes capture-session" in c for c in starts) assert any("agnes pull" in c for c in starts) assert any("agnes refresh-marketplace" in c for c in starts) assert not any("agnes push" in c for c in starts) # Other event types untouched. 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() def test_install_chains_self_upgrade_then_pull_in_one_entry(tmp_path): install_claude_hooks(tmp_path) cfg = _read_settings(tmp_path) starts = _commands_for(cfg, "SessionStart") # SessionStart has two entries: the chain (self-upgrade + pull) and # the standalone refresh-marketplace. This test pins the chain # invariant — order, both `|| true`-guarded — independent of the # refresh-marketplace entry being present. chain = next( (c for c in starts if "agnes self-upgrade" in c and "agnes pull" in c), None, ) assert chain is not None, starts assert "agnes self-upgrade --quiet" in chain assert "agnes pull --quiet" in chain # Order is encoded in the shell — self-upgrade must appear first assert chain.index("agnes self-upgrade") < chain.index("agnes pull") # Both segments carry || true so neither failure aborts the line assert chain.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) # Three SessionStart entries (capture-session, chained self-upgrade+pull, # refresh-marketplace) — re-install must not duplicate any of them. assert len(cfg["hooks"]["SessionStart"]) == 3 assert len(cfg["hooks"]["SessionEnd"]) == 1 def test_session_end_push_is_detached(tmp_path): """Regression test for the headless-mode SIGTERM bug. Claude Code in `-p` (headless) mode SIGTERMs SessionEnd hook subprocesses ~1s after launch, regardless of whether the hook is still working. `agnes push` for a typical workspace (10 session JSONLs) takes 5-30s, so a synchronous form gets killed mid-first- upload and most files never reach the server. The hook MUST run detached so the upload child survives the hook subprocess being torn down. This test pins the wrapper shape — `bash -c "( nohup ... & ) ; true"` — so a future refactor that re-introduces the synchronous form fails loudly here instead of silently regressing in production. """ install_claude_hooks(tmp_path) cfg = _read_settings(tmp_path) ends = _commands_for(cfg, "SessionEnd") assert len(ends) == 1 cmd = ends[0] assert "agnes push" in cmd, f"SessionEnd must still call agnes push; got: {cmd!r}" # Detachment markers — every one of these is load-bearing: # - `nohup` ignores SIGHUP if the controlling terminal disappears # - `&` backgrounds the child inside the subshell # - `/dev/null 2>&1` decouples stdout/stderr likewise assert "nohup" in cmd, f"SessionEnd push must use nohup for detachment; got: {cmd!r}" assert "&" in cmd, f"SessionEnd push must background with &; got: {cmd!r}" assert "/dev/null 2>&1" in cmd, ( f"SessionEnd push must redirect stdout/stderr to /dev/null; got: {cmd!r}" ) # `bash -c` wrapping is required because Claude Code on Windows runs # hook commands directly (no shell), so the subshell + redirection # syntax wouldn't parse otherwise. assert cmd.startswith("bash -c "), ( f"SessionEnd push must be wrapped in bash -c for Windows; got: {cmd!r}" ) def test_install_writes_statusline_when_absent(tmp_path): """Greenfield install: no prior statusLine → we write ours.""" install_claude_hooks(tmp_path) cfg = _read_settings(tmp_path) assert "statusLine" in cfg assert cfg["statusLine"]["type"] == "command" assert "agnes statusline" in cfg["statusLine"]["command"] def test_install_preserves_existing_user_statusline(tmp_path, capsys): """User has their own statusLine — we leave it alone and warn on stderr. Customizing the status bar is a personal preference; agnes shouldn't clobber it. Operators who want the private indicator alongside their own content can compose `agnes statusline` into their command.""" settings_path = tmp_path / ".claude" / "settings.json" settings_path.parent.mkdir(parents=True) user_statusline = {"type": "command", "command": "my-custom-status"} settings_path.write_text(json.dumps({"statusLine": user_statusline})) install_claude_hooks(tmp_path) cfg = _read_settings(tmp_path) # User's statusLine intact. assert cfg["statusLine"] == user_statusline # Warning surfaced. captured = capsys.readouterr() assert "statusLine" in captured.err def test_install_idempotent_when_statusline_already_ours(tmp_path): """Re-running install when our statusLine is already in place is a no-op, NOT a warning (idempotent re-init shouldn't spam the user).""" install_claude_hooks(tmp_path) install_claude_hooks(tmp_path) cfg = _read_settings(tmp_path) assert "agnes statusline" in cfg["statusLine"]["command"] def test_install_treats_explicit_null_statusline_as_unconfigured(tmp_path, capsys): """`"statusLine": null` (legal JSON, unusual) is treated as "no statusLine configured" rather than "user explicitly opted out". The previous truthy check (`if existing:`) silently took this path without distinguishing it from absent-key; the new check makes the behavior explicit and tested. No warning — the user didn't configure anything actionable.""" settings_path = tmp_path / ".claude" / "settings.json" settings_path.parent.mkdir(parents=True) settings_path.write_text(json.dumps({"statusLine": None})) install_claude_hooks(tmp_path) cfg = _read_settings(tmp_path) assert isinstance(cfg["statusLine"], dict) assert "agnes statusline" in cfg["statusLine"]["command"] captured = capsys.readouterr() assert "preserved" not in captured.err # no spurious warning def test_install_treats_empty_statusline_as_unconfigured(tmp_path, capsys): """Empty string is the falsy sibling of None — same treatment. Documents the boundary so future changes can't accidentally treat `""` as a non-empty truthy command.""" settings_path = tmp_path / ".claude" / "settings.json" settings_path.parent.mkdir(parents=True) settings_path.write_text(json.dumps({"statusLine": ""})) install_claude_hooks(tmp_path) cfg = _read_settings(tmp_path) assert isinstance(cfg["statusLine"], dict) assert "agnes statusline" in cfg["statusLine"]["command"] captured = capsys.readouterr() assert "preserved" not in captured.err def test_install_replaces_old_synchronous_session_end_push(tmp_path): """A workspace bootstrapped before the detachment fix has the old synchronous `agnes push --quiet 2>/dev/null || true` SessionEnd entry. On the next `agnes init`, that entry must be matched by the `agnes push` marker and replaced with the new detached form — not stacked alongside it.""" settings_path = tmp_path / ".claude" / "settings.json" settings_path.parent.mkdir(parents=True) settings_path.write_text(json.dumps({ "hooks": { "SessionEnd": [ {"hooks": [{"type": "command", "command": "agnes push --quiet 2>/dev/null || true"}]}, ], } })) install_claude_hooks(tmp_path) cfg = _read_settings(tmp_path) ends = _commands_for(cfg, "SessionEnd") assert len(ends) == 1, ends assert "nohup" in ends[0], ( f"Old synchronous push entry must have been replaced with the detached form; got: {ends!r}" ) # --------------------------------------------------------------------------- # workspace_has_agnes_hooks / maybe_refresh_claude_hooks # --------------------------------------------------------------------------- def test_workspace_has_agnes_hooks_false_for_missing_settings(tmp_path): """Fresh dir without `.claude/settings.json` is not an Agnes workspace.""" assert workspace_has_agnes_hooks(tmp_path) is False def test_workspace_has_agnes_hooks_false_for_empty_settings(tmp_path): """`.claude/settings.json` exists but is empty `{}` — not an Agnes workspace.""" sp = tmp_path / ".claude" / "settings.json" sp.parent.mkdir(parents=True) sp.write_text(json.dumps({}), encoding="utf-8") assert workspace_has_agnes_hooks(tmp_path) is False def test_workspace_has_agnes_hooks_false_for_invalid_json(tmp_path): sp = tmp_path / ".claude" / "settings.json" sp.parent.mkdir(parents=True) sp.write_text("not json", encoding="utf-8") assert workspace_has_agnes_hooks(tmp_path) is False def test_workspace_has_agnes_hooks_false_for_third_party_only_hook(tmp_path): """A settings.json with only third-party hooks (no agnes marker) is NOT an Agnes workspace — important so `agnes self-upgrade` from such a dir doesn't auto-install Agnes hooks behind the user's back.""" sp = tmp_path / ".claude" / "settings.json" sp.parent.mkdir(parents=True) sp.write_text(json.dumps({ "hooks": { "SessionStart": [ {"hooks": [{"type": "command", "command": "echo hello"}]}, ], } }), encoding="utf-8") assert workspace_has_agnes_hooks(tmp_path) is False def test_workspace_has_agnes_hooks_true_for_agnes_hook(tmp_path): install_claude_hooks(tmp_path) assert workspace_has_agnes_hooks(tmp_path) is True def test_workspace_has_agnes_hooks_true_for_just_statusline(tmp_path): """statusLine alone (no hook entries) still signals an Agnes workspace.""" sp = tmp_path / ".claude" / "settings.json" sp.parent.mkdir(parents=True) sp.write_text(json.dumps({ "statusLine": {"type": "command", "command": "agnes statusline"}, }), encoding="utf-8") assert workspace_has_agnes_hooks(tmp_path) is True def test_maybe_refresh_noop_in_non_agnes_directory(tmp_path): """Critical safety: `agnes self-upgrade` invoked from a non-Agnes dir (e.g. ~/) must NOT create `.claude/settings.json` there.""" refreshed = maybe_refresh_claude_hooks(tmp_path) assert refreshed is False assert not (tmp_path / ".claude").exists() def test_maybe_refresh_writes_new_layout_into_v048_workspace(tmp_path): """Simulate a v0.48 workspace (pre-PR-242 hook layout — chained self-upgrade+pull and old refresh-marketplace --quiet, no capture-session, no statusLine) and assert that maybe_refresh_claude_hooks brings it up to the current layout.""" sp = tmp_path / ".claude" / "settings.json" sp.parent.mkdir(parents=True) sp.write_text(json.dumps({ "hooks": { "SessionStart": [ {"hooks": [{"type": "command", "command": "agnes self-upgrade --quiet 2>/dev/null || true; " "agnes pull --quiet 2>/dev/null || true"}]}, {"hooks": [{"type": "command", "command": 'bash -c "agnes refresh-marketplace --quiet 2>/dev/null || true"'}]}, ], "SessionEnd": [ {"hooks": [{"type": "command", "command": "agnes push --quiet 2>/dev/null || true"}]}, ], } }), encoding="utf-8") refreshed = maybe_refresh_claude_hooks(tmp_path) assert refreshed is True cfg = _read_settings(tmp_path) starts = _commands_for(cfg, "SessionStart") # New layout: capture-session is now the very first SessionStart entry. assert any("agnes capture-session" in c for c in starts), ( f"v0.48→v0.49 migration must install capture-session; got: {starts!r}" ) # And refresh-marketplace must be on --check, not --quiet. refresh = next((c for c in starts if "agnes refresh-marketplace" in c), None) assert refresh is not None and "--check" in refresh, refresh ends = _commands_for(cfg, "SessionEnd") # SessionEnd push is now detached via nohup (replaces the old # synchronous form). assert any("nohup" in c for c in ends), ( f"v0.48→v0.49 migration must detach SessionEnd push; got: {ends!r}" ) # statusLine for the agnes-private indicator must also be installed. assert cfg.get("statusLine", {}).get("command", "").startswith("agnes statusline") def test_maybe_refresh_preserves_third_party_hooks(tmp_path): """Refresh must keep third-party hooks intact (same guarantee as install_claude_hooks — the migration path uses the same machinery).""" sp = tmp_path / ".claude" / "settings.json" sp.parent.mkdir(parents=True) sp.write_text(json.dumps({ "hooks": { "SessionStart": [ {"hooks": [{"type": "command", "command": "agnes self-upgrade --quiet || true"}]}, {"hooks": [{"type": "command", "command": "echo hi from another tool"}]}, ], } }), encoding="utf-8") refreshed = maybe_refresh_claude_hooks(tmp_path) assert refreshed is True cfg = _read_settings(tmp_path) starts = _commands_for(cfg, "SessionStart") assert "echo hi from another tool" in starts, ( f"Third-party hook must survive refresh; got: {starts!r}" )