Extensions in serena-hooks cli

This commit is contained in:
Michael Panchenko committed 2026-04-10 11:03:08 +02:00
1 parent 2921336726
commit 71cb3db8dd
2 files changed
+223 -45

No files matched your search

+79 -28
View File
@@ -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()
+144 -17
View File
@@ -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"})