diff --git a/src/serena/hooks.py b/src/serena/hooks.py index 264e6fe5..8988d8a8 100644 --- a/src/serena/hooks.py +++ b/src/serena/hooks.py @@ -46,22 +46,25 @@ class Hook(ABC): pass -class PreToolUseRemindAboutSerenaHook(Hook): - """Pre-tool-use hook that nudges the agent toward Serena's symbolic tools. +class PreToolUseHook(Hook, ABC): + def __init__(self, client: HookClient): + super().__init__(client) + _tool_name = self._input_data.get("tool_name") or self._input_data.get("toolName", "") or "" + _tool_name = str(_tool_name).lower().strip() + if not _tool_name: + raise ValueError("Tool name is required in the hook input data") + self._tool_name = _tool_name + self._tool_input: dict | None = self._input_data.get("tool_input") or self._input_data.get("toolInput") - Tracks consecutive uses of grep and read-file tools via a persisted - :class:`ToolUseCounter`. When the number of recent calls reaches the - configured threshold, a deny response is emitted with a reminder to - use symbolic alternatives. The counter resets whenever a Serena tool - is invoked or a deny is emitted, so the agent is only reminded after - sustained non-symbolic tool use without intermittent Serena calls. - """ + # only relevant in claude code at the moment, (not all events include this field; default to empty string) + raw_permission_mode = self._input_data.get("permission_mode") or self._input_data.get("permissionMode") or "" + self._permission_mode = str(raw_permission_mode).strip() @dataclass class OutputData: permission_decision: Literal["deny", "allow"] permission_decision_reason: str - additional_context: str + additional_context: str = "" def to_json_string(self) -> str: hook_output = { @@ -74,13 +77,29 @@ class PreToolUseRemindAboutSerenaHook(Hook): } return json.dumps(hook_output) + def is_serena_tool(self) -> bool: + return "serena" in self._tool_name + + +class PreToolUseRemindAboutSerenaHook(PreToolUseHook): + """Pre-tool-use hook that nudges the agent toward Serena's symbolic tools. + + Tracks consecutive uses of grep and read-file tools via a persisted + :class:`ToolUseCounter`. When the number of recent calls reaches the + configured threshold, a deny response is emitted with a reminder to + use symbolic alternatives. The counter resets whenever a Serena tool + is invoked or a deny is emitted, so the agent is only reminded after + sustained non-symbolic tool use without intermittent Serena calls. + """ + @dataclass class ToolUseCounter: _FILE_NAME = "tool_use_counter.pkl" _GREP_USES_THRESHOLD = 3 _READ_FILE_USES_THRESHOLD = 3 - _PERIOD_TO_RESET_COUNTERS_SECONDS = 10 + _GREP_RESET_PERIOD_SECONDS = 10 + _READ_FILE_RESET_PERIOD_SECONDS = 20 n_recent_read_file_uses: int = 0 n_recent_grep_uses: int = 0 @@ -121,11 +140,11 @@ class PreToolUseRemindAboutSerenaHook(Hook): return now = hook.triggered_at_timestamp - period = self._PERIOD_TO_RESET_COUNTERS_SECONDS # update grep counter if hook.is_grep_tool(): - if self.last_grep_use_timestamp is not None and (now - self.last_grep_use_timestamp).total_seconds() <= period: + grep_period = self._GREP_RESET_PERIOD_SECONDS + if self.last_grep_use_timestamp is not None and (now - self.last_grep_use_timestamp).total_seconds() <= grep_period: self.n_recent_grep_uses += 1 else: self.n_recent_grep_uses = 1 @@ -133,7 +152,11 @@ class PreToolUseRemindAboutSerenaHook(Hook): # update read file counter if hook.is_read_file_tool(): - if self.last_read_file_use_timestamp is not None and (now - self.last_read_file_use_timestamp).total_seconds() <= period: + read_period = self._READ_FILE_RESET_PERIOD_SECONDS + if ( + self.last_read_file_use_timestamp is not None + and (now - self.last_read_file_use_timestamp).total_seconds() <= read_period + ): self.n_recent_read_file_uses += 1 else: self.n_recent_read_file_uses = 1 @@ -147,12 +170,6 @@ class PreToolUseRemindAboutSerenaHook(Hook): def __init__(self, client: HookClient): super().__init__(client) - _tool_name = self._input_data.get("tool_name") or self._input_data.get("toolName", "") or "" - _tool_name = str(_tool_name).lower().strip() - if not _tool_name: - raise ValueError("Tool name is required in the hook input data") - self._tool_name = _tool_name - self._tool_input: dict | None = self._input_data.get("tool_input") or self._input_data.get("toolInput") self._tool_call_counter = self.ToolUseCounter.load(self) def is_grep_tool(self) -> bool: @@ -165,23 +182,20 @@ class PreToolUseRemindAboutSerenaHook(Hook): return self._tool_name == "read" return "read" in self._tool_name and "file" in self._tool_name - def is_serena_tool(self) -> bool: - return "serena" in self._tool_name - def execute(self) -> None: self._tool_call_counter.update(self) output_data = None if self._tool_call_counter.too_many_recent_greps(): output_data = self.OutputData( permission_decision="deny", - permission_decision_reason="Not using symbolic tools. You can continue using grep now if needed, the counter was reset.", - additional_context="You were using many grep calls recently. Consider using Serena's symbolic search tools instead.", + permission_decision_reason="Too many consecutive grep calls without using symbolic tools.", + additional_context="You were using many grep calls recently. Consider using Serena's symbolic search tools instead. You can continue using grep now if needed, the counter was reset.", ) if self._tool_call_counter.too_many_recent_reads(): output_data = self.OutputData( permission_decision="deny", - permission_decision_reason="Not using symbolic tools. You can continue using read now if needed, the counter was reset.", - additional_context="You were using many read file calls recently. Consider using Serena's symbolic overview and search instead.", + permission_decision_reason="Too many consecutive read calls without using symbolic tools.", + additional_context="You were using many read file calls recently. Consider using Serena's symbolic overview and search instead. You can continue using read now if needed, the counter was reset", ) if output_data is not None: self._tool_call_counter.reset() @@ -192,7 +206,7 @@ class PreToolUseRemindAboutSerenaHook(Hook): class SessionStartActivateProjectHook(Hook): def execute(self) -> None: message = ( - "**IMPORTANT**: Activate the current working directory as project using Serena's tools. " + "**IMPORTANT**: Activate the current working directory as project using Serena's tools if not already done. " "If you haven't read Serena Instructions Manual yet, do so. " "Follow this instruction before doing anything else." ) @@ -210,6 +224,34 @@ class SessionEndCleanupHook(Hook): shutil.rmtree(self.session_persistence_dir, ignore_errors=True) +class PreToolUseAutoApproveSerenaHook(PreToolUseHook): + """Pre-tool-use hook that auto-approves Serena tool calls while the client is in accept-edits mode. + + Claude Code's ``acceptEdits`` permission mode only applies to its built-in editing tools; + Serena's destructive tools (e.g. ``replace_symbol_body`` or ``rename_symbol``) would still + prompt the user on every call. This hook emits an ``allow`` decision for any Serena MCP + tool call whenever the client reports ``acceptEdits`` as the active permission mode, so + blanket edit approvals also cover Serena's tools. In all other situations it stays silent, + preserving the default approval flow. + """ + + _ACCEPT_EDITS_MODE = "acceptEdits" + + def is_accept_edits_mode(self) -> bool: + return self._permission_mode == self._ACCEPT_EDITS_MODE + + def execute(self) -> None: + # only emit a decision when both the tool and the mode match; stay silent otherwise + if not self.is_serena_tool() or not self.is_accept_edits_mode(): + return + + output_data = self.OutputData( + permission_decision="allow", + permission_decision_reason="Auto-approved: Serena tool call while client is in acceptEdits mode.", + ) + click.echo(output_data.to_json_string()) + + _client_option = click.option( "--client", type=click.Choice([e.value for e in HookClient], case_sensitive=False), @@ -247,5 +289,14 @@ class HookCommands(AutoRegisteringGroup): def remind(client: str) -> None: PreToolUseRemindAboutSerenaHook(HookClient(client)).execute() + @staticmethod + @click.command( + "auto-approve", + help="Set this as hook at PreToolUse to auto-approve Serena tool calls while the client is in acceptEdits mode (Claude Code).", + ) + @_client_option + def auto_approve(client: str) -> None: + PreToolUseAutoApproveSerenaHook(HookClient(client)).execute() + hook_commands = HookCommands() diff --git a/test/serena/test_hooks.py b/test/serena/test_hooks.py index e8291bc7..3023261f 100644 --- a/test/serena/test_hooks.py +++ b/test/serena/test_hooks.py @@ -10,6 +10,7 @@ from click.testing import CliRunner from serena.hooks import ( HookClient, + PreToolUseAutoApproveSerenaHook, PreToolUseRemindAboutSerenaHook, SessionEndCleanupHook, SessionStartActivateProjectHook, @@ -62,17 +63,33 @@ class TestPreToolUseRemindAboutSerenaHook: with pytest.raises(ValueError, match="Session ID is required"): PreToolUseRemindAboutSerenaHook(HookClient.CLAUDE_CODE) - def test_grep_tool_detection(self, tmp_path: Path): + def test_grep_tool_detection_claude_code(self, tmp_path: Path): + """Claude Code uses the exact tool name ``Grep`` (lowercased to ``grep``).""" + for name, expected in [("grep", True), ("grep_search", False), ("mcp_grep", False), ("read", False)]: + with patch("sys.stdin", _make_stdin(_base_input(tool_name=name))), patch("serena.hooks.serena_home_dir", str(tmp_path)): + hook = PreToolUseRemindAboutSerenaHook(HookClient.CLAUDE_CODE) + assert hook.is_grep_tool() == expected, f"is_grep_tool() wrong for {name} (claude-code)" + + def test_grep_tool_detection_non_claude_code(self, tmp_path: Path): + """Non-Claude-Code clients fall back to substring matching to cover verbose tool names.""" for name, expected in [("grep_search", True), ("mcp_grep", True), ("read_file", False), ("serena_find", False)]: with patch("sys.stdin", _make_stdin(_base_input(tool_name=name))), patch("serena.hooks.serena_home_dir", str(tmp_path)): - hook = PreToolUseRemindAboutSerenaHook(HookClient.CLAUDE_CODE) - assert hook.is_grep_tool() == expected, f"is_grep_tool() wrong for {name}" + hook = PreToolUseRemindAboutSerenaHook(HookClient.VSCODE) + assert hook.is_grep_tool() == expected, f"is_grep_tool() wrong for {name} (vscode)" - def test_read_file_tool_detection(self, tmp_path: Path): - for name, expected in [("read_file", True), ("readFile", True), ("grep_search", False), ("file_writer", False)]: + def test_read_file_tool_detection_claude_code(self, tmp_path: Path): + """Claude Code uses the exact tool name ``Read`` (lowercased to ``read``).""" + for name, expected in [("read", True), ("read_file", False), ("readFile", False), ("grep", False)]: with patch("sys.stdin", _make_stdin(_base_input(tool_name=name))), patch("serena.hooks.serena_home_dir", str(tmp_path)): hook = PreToolUseRemindAboutSerenaHook(HookClient.CLAUDE_CODE) - assert hook.is_read_file_tool() == expected, f"is_read_file_tool() wrong for {name}" + assert hook.is_read_file_tool() == expected, f"is_read_file_tool() wrong for {name} (claude-code)" + + def test_read_file_tool_detection_non_claude_code(self, tmp_path: Path): + """Non-Claude-Code clients fall back to substring matching requiring both ``read`` and ``file``.""" + for name, expected in [("read_file", True), ("readFile", True), ("grep_search", False), ("file_writer", False)]: + with patch("sys.stdin", _make_stdin(_base_input(tool_name=name))), patch("serena.hooks.serena_home_dir", str(tmp_path)): + hook = PreToolUseRemindAboutSerenaHook(HookClient.VSCODE) + assert hook.is_read_file_tool() == expected, f"is_read_file_tool() wrong for {name} (vscode)" def test_serena_tool_detection(self, tmp_path: Path): for name, expected in [("mcp_serena_find_symbol", True), ("serena_overview", True), ("grep_search", False)]: @@ -83,14 +100,14 @@ class TestPreToolUseRemindAboutSerenaHook: def test_no_output_below_threshold(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): """Below the threshold, the hook should produce no output (tool is allowed).""" for _ in range(ToolUseCounter._GREP_USES_THRESHOLD - 1): - with patch("sys.stdin", _make_stdin(_base_input("grep_search"))), patch("serena.hooks.serena_home_dir", str(tmp_path)): + with patch("sys.stdin", _make_stdin(_base_input("grep"))), patch("serena.hooks.serena_home_dir", str(tmp_path)): PreToolUseRemindAboutSerenaHook(HookClient.CLAUDE_CODE).execute() assert capsys.readouterr().out == "" def test_deny_output_after_threshold_greps_claude_code(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): """After reaching the grep threshold, the hook should output a deny.""" for _ in range(ToolUseCounter._GREP_USES_THRESHOLD): - with patch("sys.stdin", _make_stdin(_base_input("grep_search"))), patch("serena.hooks.serena_home_dir", str(tmp_path)): + with patch("sys.stdin", _make_stdin(_base_input("grep"))), patch("serena.hooks.serena_home_dir", str(tmp_path)): PreToolUseRemindAboutSerenaHook(HookClient.CLAUDE_CODE).execute() output = capsys.readouterr().out.strip() @@ -114,7 +131,7 @@ class TestPreToolUseRemindAboutSerenaHook: def test_deny_output_after_threshold_reads(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): """After reaching the read file threshold, the hook should output a deny.""" for _ in range(ToolUseCounter._READ_FILE_USES_THRESHOLD): - with patch("sys.stdin", _make_stdin(_base_input("read_file"))), patch("serena.hooks.serena_home_dir", str(tmp_path)): + with patch("sys.stdin", _make_stdin(_base_input("read"))), patch("serena.hooks.serena_home_dir", str(tmp_path)): PreToolUseRemindAboutSerenaHook(HookClient.CLAUDE_CODE).execute() output = capsys.readouterr().out.strip() @@ -126,13 +143,13 @@ class TestPreToolUseRemindAboutSerenaHook: def test_serena_tool_resets_counters(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): """Using a Serena tool should reset counters, so the threshold is not reached.""" for _ in range(ToolUseCounter._GREP_USES_THRESHOLD - 1): - with patch("sys.stdin", _make_stdin(_base_input("grep_search"))), patch("serena.hooks.serena_home_dir", str(tmp_path)): + with patch("sys.stdin", _make_stdin(_base_input("grep"))), patch("serena.hooks.serena_home_dir", str(tmp_path)): PreToolUseRemindAboutSerenaHook(HookClient.CLAUDE_CODE).execute() with patch("sys.stdin", _make_stdin(_base_input("mcp_serena_find_symbol"))), patch("serena.hooks.serena_home_dir", str(tmp_path)): PreToolUseRemindAboutSerenaHook(HookClient.CLAUDE_CODE).execute() - with patch("sys.stdin", _make_stdin(_base_input("grep_search"))), patch("serena.hooks.serena_home_dir", str(tmp_path)): + with patch("sys.stdin", _make_stdin(_base_input("grep"))), patch("serena.hooks.serena_home_dir", str(tmp_path)): PreToolUseRemindAboutSerenaHook(HookClient.CLAUDE_CODE).execute() assert capsys.readouterr().out == "" @@ -140,11 +157,11 @@ class TestPreToolUseRemindAboutSerenaHook: def test_counter_resets_after_deny(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): """After a deny is emitted, the counter is reset so the next burst starts fresh.""" for _ in range(ToolUseCounter._GREP_USES_THRESHOLD): - with patch("sys.stdin", _make_stdin(_base_input("grep_search"))), patch("serena.hooks.serena_home_dir", str(tmp_path)): + with patch("sys.stdin", _make_stdin(_base_input("grep"))), patch("serena.hooks.serena_home_dir", str(tmp_path)): PreToolUseRemindAboutSerenaHook(HookClient.CLAUDE_CODE).execute() capsys.readouterr() - with patch("sys.stdin", _make_stdin(_base_input("grep_search"))), patch("serena.hooks.serena_home_dir", str(tmp_path)): + with patch("sys.stdin", _make_stdin(_base_input("grep"))), patch("serena.hooks.serena_home_dir", str(tmp_path)): PreToolUseRemindAboutSerenaHook(HookClient.CLAUDE_CODE).execute() assert capsys.readouterr().out == "" @@ -168,7 +185,7 @@ class TestToolUseCounter: def test_update_resets_grep_outside_period(self): counter = ToolUseCounter() now = datetime.now() - counter.last_grep_use_timestamp = now - timedelta(seconds=ToolUseCounter._PERIOD_TO_RESET_COUNTERS_SECONDS + 1) + counter.last_grep_use_timestamp = now - timedelta(seconds=ToolUseCounter._GREP_RESET_PERIOD_SECONDS + 1) counter.n_recent_grep_uses = 2 hook = self._make_hook_stub("grep_search", now) @@ -191,7 +208,7 @@ class TestToolUseCounter: def test_update_resets_read_file_outside_period(self): counter = ToolUseCounter() now = datetime.now() - counter.last_read_file_use_timestamp = now - timedelta(seconds=ToolUseCounter._PERIOD_TO_RESET_COUNTERS_SECONDS + 1) + counter.last_read_file_use_timestamp = now - timedelta(seconds=ToolUseCounter._READ_FILE_RESET_PERIOD_SECONDS + 1) counter.n_recent_read_file_uses = 2 hook = self._make_hook_stub("read_file", now) @@ -246,13 +263,90 @@ class TestToolUseCounter: @staticmethod def _make_hook_stub(tool_name: str, timestamp: datetime) -> PreToolUseRemindAboutSerenaHook: - """Create a minimal stub that satisfies ToolUseCounter.update without reading stdin.""" + """Create a minimal stub that satisfies ToolUseCounter.update without reading stdin. + + Uses ``HookClient.VSCODE`` so that ``is_grep_tool`` / ``is_read_file_tool`` apply the + substring-matching branch — the counter tests below feed verbose tool names like + ``grep_search`` / ``read_file`` which are only recognized under the non-Claude-Code + branch (Claude Code uses exact names ``grep`` / ``read``). + """ stub = object.__new__(PreToolUseRemindAboutSerenaHook) stub._tool_name = tool_name.lower() + stub._client = HookClient.VSCODE stub.triggered_at_timestamp = timestamp return stub +class TestPreToolUseAutoApproveSerenaHook: + """Tests for the auto-approve hook that allows Serena tools while the client is in ``acceptEdits`` mode.""" + + @staticmethod + def _approve_input( + tool_name: str = "mcp__serena__find_symbol", + permission_mode: str | None = "acceptEdits", + session_id: str = "auto-approve-session", + permission_mode_key: str = "permission_mode", + ) -> dict: + data: dict = { + "session_id": session_id, + "tool_name": tool_name, + "tool_input": {}, + } + if permission_mode is not None: + data[permission_mode_key] = permission_mode + return data + + def test_approves_serena_tool_in_accept_edits_mode(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """When the tool is a Serena tool and the mode is ``acceptEdits``, an allow decision is emitted.""" + stdin_data = self._approve_input() + with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute() + + output = capsys.readouterr().out.strip() + result = json.loads(output) + hook_output = result["hookSpecificOutput"] + assert hook_output["hookEventName"] == "PreToolUse" + assert hook_output["permissionDecision"] == "allow" + assert "acceptedits" in hook_output["permissionDecisionReason"].lower() + + def test_accepts_camel_case_permission_mode(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """The hook also reads the ``permissionMode`` (camelCase) variant of the field.""" + stdin_data = self._approve_input(permission_mode_key="permissionMode") + with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute() + + output = capsys.readouterr().out.strip() + assert json.loads(output)["hookSpecificOutput"]["permissionDecision"] == "allow" + + def test_stays_silent_for_non_serena_tool(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """Non-Serena tools get no decision even in ``acceptEdits`` mode (the hook stays silent).""" + stdin_data = self._approve_input(tool_name="Grep") + with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute() + assert capsys.readouterr().out == "" + + def test_stays_silent_in_default_mode(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """Serena tools in ``default`` mode get no decision (the hook stays silent).""" + stdin_data = self._approve_input(permission_mode="default") + with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute() + assert capsys.readouterr().out == "" + + def test_stays_silent_in_plan_mode(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """Other permission modes (e.g. ``plan``) must not trigger an auto-approve.""" + stdin_data = self._approve_input(permission_mode="plan") + with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute() + assert capsys.readouterr().out == "" + + def test_stays_silent_when_permission_mode_missing(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """If ``permission_mode`` is missing from the input, the hook stays silent rather than erroring.""" + stdin_data = self._approve_input(permission_mode=None) + with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute() + assert capsys.readouterr().out == "" + + class TestSessionStartActivateProjectHook: def test_outputs_activation_message(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): stdin_data = {"session_id": "s1"} @@ -314,7 +408,7 @@ class TestHookCli: """Invoke the remind command enough times to trigger a deny.""" runner = CliRunner() for _ in range(ToolUseCounter._GREP_USES_THRESHOLD): - stdin_json = json.dumps({"session_id": "cli-remind", "tool_name": "grep_search", "tool_input": {}}) + stdin_json = json.dumps({"session_id": "cli-remind", "tool_name": "grep", "tool_input": {}}) with patch("serena.hooks.serena_home_dir", str(tmp_path)): result = runner.invoke(hook_commands, ["remind", "--client", "claude-code"], input=stdin_json) assert result.exit_code == 0 @@ -322,6 +416,39 @@ class TestHookCli: output = json.loads(result.output) assert output["hookSpecificOutput"]["permissionDecision"] == "deny" + def test_auto_approve_command(self, tmp_path: Path): + """The ``auto-approve`` CLI command emits an allow for a Serena tool in acceptEdits mode.""" + stdin_json = json.dumps( + { + "session_id": "cli-auto-approve", + "tool_name": "mcp__serena__find_symbol", + "tool_input": {}, + "permission_mode": "acceptEdits", + } + ) + runner = CliRunner() + with patch("serena.hooks.serena_home_dir", str(tmp_path)): + result = runner.invoke(hook_commands, ["auto-approve", "--client", "claude-code"], input=stdin_json) + assert result.exit_code == 0 + output = json.loads(result.output) + assert output["hookSpecificOutput"]["permissionDecision"] == "allow" + + def test_auto_approve_command_stays_silent_in_default_mode(self, tmp_path: Path): + """The ``auto-approve`` CLI command emits nothing when the mode is not ``acceptEdits``.""" + stdin_json = json.dumps( + { + "session_id": "cli-auto-approve-default", + "tool_name": "mcp__serena__find_symbol", + "tool_input": {}, + "permission_mode": "default", + } + ) + runner = CliRunner() + with patch("serena.hooks.serena_home_dir", str(tmp_path)): + result = runner.invoke(hook_commands, ["auto-approve", "--client", "claude-code"], input=stdin_json) + assert result.exit_code == 0 + assert result.output == "" + def test_client_default_is_claude_code(self, tmp_path: Path): """When --client is omitted, it defaults to claude-code.""" stdin_json = json.dumps({"session_id": "cli-default"})